/* SleepLib PRS1 Loader Implementation
 *
 * Copyright (c) 2019-2022 The OSCAR Team
 * Copyright (c) 2011-2018 Mark Watkins <mark@jedimark.net>
 *
 * This file is subject to the terms and conditions of the GNU General Public
 * License. See the file COPYING in the main directory of the source code
 * for more details. */

#include <QApplication>
#include <QString>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QBuffer>
#include <QDataStream>
#include <QMessageBox>
#include <QDebug>
#include <cmath>

#include "SleepLib/schema.h"
#include "SleepLib/importcontext.h"
#include "prs1_loader.h"
#include "prs1_parser.h"
#include "SleepLib/session.h"
#include "SleepLib/calcs.h"
#include "rawdata.h"


// Disable this to cut excess debug messages

#define DEBUG_SUMMARY


//const int PRS1_MAGIC_NUMBER = 2;
//const int PRS1_SUMMARY_FILE=1;
//const int PRS1_EVENT_FILE=2;
//const int PRS1_WAVEFORM_FILE=5;


//********************************************************************************************
/// IMPORTANT!!!
//********************************************************************************************
// Please INCREMENT the prs1_data_version in prs1_loader.h when making changes to this loader
// that change loader behaviour or modify channels.
//********************************************************************************************

QString ts(qint64 msecs)
{
    // TODO: make this UTC so that tests don't vary by where they're run
    return QDateTime::fromMSecsSinceEpoch(msecs).toString(Qt::ISODate);
}


ChannelID PRS1_Mode = 0;
ChannelID PRS1_TimedBreath = 0, PRS1_HumidMode = 0, PRS1_TubeTemp = 0;
ChannelID PRS1_FlexLock = 0, PRS1_TubeLock = 0, PRS1_RampType = 0;
ChannelID PRS1_BackupBreathMode = 0, PRS1_BackupBreathRate = 0, PRS1_BackupBreathTi = 0;
ChannelID PRS1_AutoTrial = 0, PRS1_EZStart = 0, PRS1_RiseTime = 0, PRS1_RiseTimeLock = 0;
ChannelID PRS1_PeakFlow = 0;
ChannelID PRS1_VariableBreathing = 0;  // TODO: UNCONFIRMED, but seems to match sample data

QString PRS1Loader::PresReliefLabel() { return QObject::tr(""); }
ChannelID PRS1Loader::PresReliefMode() { return PRS1_FlexMode; }
ChannelID PRS1Loader::PresReliefLevel() { return PRS1_FlexLevel; }
ChannelID PRS1Loader::CPAPModeChannel() { return PRS1_Mode; }
ChannelID PRS1Loader::HumidifierConnected() { return PRS1_HumidStatus; }
ChannelID PRS1Loader::HumidifierLevel() { return PRS1_HumidLevel; }


struct PRS1TestedModel
{
    QString model;
    int family;
    int familyVersion;
    const char* name;
};

static const PRS1TestedModel s_PRS1TestedModels[] = {
    // This first set says "(Philips Respironics)" intead of "(System One)" on official reports.
    { "251P", 0, 2, "REMstar Plus (System One)" },  // (brick)
    { "450P", 0, 2, "REMstar Pro (System One)" },
    { "450P", 0, 3, "REMstar Pro (System One)" },
    { "451P", 0, 2, "REMstar Pro (System One)" },
    { "451P", 0, 3, "REMstar Pro (System One)" },
    { "452P", 0, 3, "REMstar Pro (System One)" },
    { "550P", 0, 2, "REMstar Auto (System One)" },
    { "550P", 0, 3, "REMstar Auto (System One)" },
    { "551P", 0, 2, "REMstar Auto (System One)" },
    { "650P", 0, 2, "BiPAP Pro (System One)" },
    { "750P", 0, 2, "BiPAP Auto (System One)" },

    { "261CA",  0, 4, "REMstar Plus (System One 60 Series)" },  // (brick)
    { "261P",   0, 4, "REMstar Plus (System One 60 Series)" },  // (brick)
    { "460P",   0, 4, "REMstar Pro (System One 60 Series)" },
    { "460PBT", 0, 4, "REMstar Pro (System One 60 Series)" },  // evidently built-in bluetooth
    { "461P",   0, 4, "REMstar Pro (System One 60 Series)" },
    { "462P",   0, 4, "REMstar Pro (System One 60 Series)" },
    { "461CA",  0, 4, "REMstar Pro (System One 60 Series)" },
    { "560P",   0, 4, "REMstar Auto (System One 60 Series)" },
    { "560PBT", 0, 4, "REMstar Auto (System One 60 Series)" },
    { "561P",   0, 4, "REMstar Auto (System One 60 Series)" },
    { "562P",   0, 4, "REMstar Auto (System One 60 Series)" },
    { "660P",   0, 4, "BiPAP Pro (System One 60 Series)" },
    { "760P",   0, 4, "BiPAP Auto (System One 60 Series)" },
    
    { "501V",   0, 5, "Dorma 500 Auto (System One 60 Series)" },  // (brick)

    { "200X110", 0, 6, "DreamStation CPAP" },  // (brick)
    { "400G110", 0, 6, "DreamStation Go" },
    { "400X110", 0, 6, "DreamStation CPAP Pro" },
    { "400X120", 0, 6, "DreamStation CPAP Pro" },
    { "400X130", 0, 6, "DreamStation CPAP Pro" },
    { "400X150", 0, 6, "DreamStation CPAP Pro" },
    { "401X150", 0, 6, "DreamStation CPAP Pro with Auto-Trial" },
    { "500X110", 0, 6, "DreamStation Auto CPAP" },
    { "500X120", 0, 6, "DreamStation Auto CPAP" },
    { "500X130", 0, 6, "DreamStation Auto CPAP" },
    { "500X140", 0, 6, "DreamStation Auto CPAP with A-Flex" },
    { "500X150", 0, 6, "DreamStation Auto CPAP" },
    { "500X180", 0, 6, "DreamStation Auto CPAP" },
    { "501X120", 0, 6, "DreamStation Auto CPAP with P-Flex" },
    { "500G110", 0, 6, "DreamStation Go Auto" },
    { "500G120", 0, 6, "DreamStation Go Auto" },
    { "502G150", 0, 6, "DreamStation Go Auto" },
    { "600X110", 0, 6, "DreamStation BiPAP Pro" },
    { "600X150", 0, 6, "DreamStation BiPAP Pro" },
    { "700X110", 0, 6, "DreamStation Auto BiPAP" },
    { "700X120", 0, 6, "DreamStation Auto BiPAP" },
    { "700X130", 0, 6, "DreamStation Auto BiPAP" },
    { "700X150", 0, 6, "DreamStation Auto BiPAP" },
    
    { "520X110C", 0, 6, "DreamStation 2 Auto CPAP Advanced" },  // based on bottom label, boot screen says "Advanced Auto CPAP"

    { "950P",    5, 0, "BiPAP AutoSV Advanced System One" },
    { "951P",    5, 0, "BiPAP AutoSV Advanced System One" },
    { "960P",    5, 1, "BiPAP autoSV Advanced (System One 60 Series)" },
    { "961P",    5, 1, "BiPAP autoSV Advanced (System One 60 Series)" },
    { "960T",    5, 2, "BiPAP autoSV Advanced 30 (System One 60 Series)" },  // omits "(System One 60 Series)" on official reports
    { "900X110", 5, 3, "DreamStation BiPAP autoSV" },
    { "900X120", 5, 3, "DreamStation BiPAP autoSV" },
    { "900X150", 5, 3, "DreamStation BiPAP autoSV" },
    
    { "1061401",  3, 0, "BiPAP S/T (C Series)" },
    { "1061T",    3, 3, "BiPAP S/T 30 (System One 60 Series)" },
    { "1160P",    3, 3, "BiPAP AVAPS 30 (System One 60 Series)" },
    { "1030X110", 3, 6, "DreamStation BiPAP S/T 30" },
    { "1030X150", 3, 6, "DreamStation BiPAP S/T 30 with AAM" },
    { "1130X110", 3, 6, "DreamStation BiPAP AVAPS 30" },
    { "1131X150", 3, 6, "DreamStation BiPAP AVAPS 30 AE" },
    { "1130X200", 3, 6, "DreamStation BiPAP AVAPS 30" },
    
    { "", 0, 0, "" },
};
PRS1ModelInfo s_PRS1ModelInfo;

PRS1ModelInfo::PRS1ModelInfo()
{
    for (int i = 0; !s_PRS1TestedModels[i].model.isEmpty(); i++) {
        const PRS1TestedModel & model = s_PRS1TestedModels[i];
        m_testedModels[model.family][model.familyVersion].append(model.model);
        
        m_modelNames[model.model] = model.name;
    }
    
    m_bricks = { "251P", "261CA", "261P", "200X110", "501V" };
}

bool PRS1ModelInfo::IsSupported(int family, int familyVersion) const
{
    if (m_testedModels.value(family).contains(familyVersion)) {
        return true;
    }
    return false;
}

bool PRS1ModelInfo::IsTested(const QString & model, int family, int familyVersion) const
{
    if (m_testedModels.value(family).value(familyVersion).contains(model)) {
        return true;
    }
    // Some 500X150 C0/C1 folders have contained this bogus model number in their PROP.TXT file,
    // with the same serial number seen in the main PROP.TXT file that shows the real model number.
    if (model == "100X100") {
#ifndef UNITTEST_MODE
        qDebug() << "Ignoring 100X100 for untested alert";
#endif
        return true;
    }
    return false;
};

static bool getVersionFromProps(const QHash<QString,QString> & props, int & family, int & familyVersion)
{
    bool ok;
    family = props["Family"].toInt(&ok, 10);
    if (ok) {
        familyVersion = props["FamilyVersion"].toInt(&ok, 10);
    }
    return ok;
}

bool PRS1ModelInfo::IsSupported(const QHash<QString,QString> & props) const
{
    int family, familyVersion;
    bool ok = getVersionFromProps(props, family, familyVersion);
    if (ok) {
        ok = IsSupported(family, familyVersion);
    }
    return ok;
}

bool PRS1ModelInfo::IsTested(const QHash<QString,QString> & props) const
{
    int family, familyVersion;
    bool ok = getVersionFromProps(props, family, familyVersion);
    if (ok) {
        ok = IsTested(props["ModelNumber"], family, familyVersion);
    }
    return ok;
};

bool PRS1ModelInfo::IsBrick(const QString & model) const
{
    bool is_brick = false;
    
    if (m_modelNames.contains(model)) {
        is_brick = m_bricks.contains(model);
    } else if (model.length() > 0) {
        // If we haven't seen it before, assume any 2xx is a brick.
        is_brick = (model.at(0) == QChar('2'));
    }
    
    return is_brick;
};

const char* PRS1ModelInfo::Name(const QString & model) const
{
    const char* name;
    if (m_modelNames.contains(model)) {
        name = m_modelNames[model];
    } else {
        name = "Unknown Model";
    }
    return name;
};


//********************************************************************************************

#include "SleepLib/thirdparty/botan_all.h"

// Decoder for DreamStation 2 files, which encrypt the actual data after a header with the key.
// The public read/seek/pos/etc. functions are all in terms of the decoded stream.
class PRDS2File : public RawDataFile
{
  public:
    PRDS2File(class QFile & file);
    virtual ~PRDS2File() {};
    bool isValid() const;
    QString guid() const;
  private:
    bool parseDS2Header();
    int read16();
    QByteArray readBytes();
    bool initializeKey();
    bool decryptData();
    QByteArray m_iv;
    QByteArray e, j, k;
    QByteArray m_payload_key;
    QByteArray m_payload_tag;
    QBuffer m_payload;
    bool m_valid;
  protected:
    virtual qint64 readData(char *data, qint64 maxSize);
    virtual bool seek(qint64 pos);
    virtual qint64 pos() const;
    virtual qint64 size() const;
    
    QByteArray m_guid;
    static const int m_header_size = 0xCA;
};

PRDS2File::PRDS2File(class QFile & file)
    : RawDataFile(file)
{
    bool valid = parseDS2Header();
    if (valid) {
        valid = initializeKey();
        if (valid) {
            valid = decryptData();
        }
    }
    m_valid = valid;
    if (m_valid) {
        seek(0);  // initialize internal position
    }
}

bool PRDS2File::isValid() const {
    return m_valid;
}

QString PRDS2File::guid() const {
    QString guid(m_guid);
    return guid;
}

bool PRDS2File::seek(qint64 pos)
{
    if (!m_valid) {
        qWarning() << "seeking in unsupported DS2 file";
        return false;
    }
    QIODevice::seek(pos);
    return m_payload.seek(pos);
}

qint64 PRDS2File::pos() const
{
    if (!m_valid) {
        qWarning() << "querying pos in unsupported DS2 file";
        return 0;
    }
    return m_payload.pos();
}

qint64 PRDS2File::size() const
{
    return m_payload.size();
}

qint64 PRDS2File::readData(char *data, qint64 maxSize)
{
    if (!m_valid) {
        qWarning() << "reading from unsupported DS2 file";
        return -1;
    }
    return m_payload.read(data, maxSize);
}

bool PRDS2File::decryptData()
{
    bool valid = false;
    try {
        QByteArray ciphertext = m_device.read(m_device.size() - m_device.pos());

        const std::vector<uint8_t> key(m_payload_key.begin(), m_payload_key.end());
        const std::vector<uint8_t> iv(m_iv.begin(), m_iv.end());
        const std::vector<uint8_t> tag(m_payload_tag.begin(), m_payload_tag.end());

        Botan::secure_vector<uint8_t> message(ciphertext.begin(), ciphertext.end());
        message += tag;

        std::unique_ptr<Botan::Cipher_Mode> dec = Botan::Cipher_Mode::create("AES-256/GCM", Botan::DECRYPTION);
        dec->set_key(key);
        dec->start(iv);
        try {
            dec->finish(message);
            //qDebug() << QString::fromStdString(Botan::hex_encode(message.data(), message.size()));
            m_payload.setData((char*) message.data(), message.size());
            m_payload.open(QIODevice::ReadOnly);
            valid = true;
        }
        catch (const Botan::Invalid_Authentication_Tag& e) {
            qWarning() << "DS2 payload doesn't match tag in" << name();
        }
    }
    catch (exception& e) {
        // Make sure no Botan exceptions leak out and terminate the application.
        qWarning() << "*** DS2 unexpected exception decrypting" << name() << ":" << e.what();
    }
    return valid;
}

bool PRDS2File::initializeKey()
{
    bool valid = false;
    
    // TODO: Figure out how the non-default payload key is derived.
    static const unsigned char knownIV[] = { 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1 };
    static const unsigned char knownE[] = { 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1 };
    static const unsigned char knownJ[] = {
        0x9a, 0x93, 0x15, 0xc8, 0xd4, 0x24, 0xef, 0x7f, 0xa6, 0xa7, 0x9f, 0xce, 0x82, 0xdd, 0x5d, 0xfe,
        0xde, 0x8d, 0x4f, 0x9f, 0x15, 0x32, 0x4d, 0x2e, 0x6d, 0x1d, 0x6e, 0xc4, 0xcb, 0x5f, 0xce, 0x64
    };
    static const unsigned char knownK[] = {
        0xc1, 0x70, 0x9e, 0xe9, 0xf0, 0xdf, 0x0a, 0xd4, 0x79, 0xd5, 0xaa, 0x07, 0x97, 0xd4, 0x5c, 0x33
    };
    if (m_iv == QByteArray((const char*) knownIV, sizeof(knownIV)) && e == QByteArray((const char*) knownE, sizeof(knownE))) {
        if (j == QByteArray((const char*) knownJ, sizeof(knownJ)) && k == QByteArray((const char*) knownK, sizeof(knownK))) {
            m_payload_key = e + e;  // This doesn't seem to apply to non-default keys.
            valid = true;
        } else {
            qWarning() << "*** DS2 unexpected j,k for default key in" << name();
        }
    } else {
        qWarning() << "DS2 unknown key for" << name();
    }
    return valid;
}

bool PRDS2File::parseDS2Header()
{
    int a = read16();
    int b = read16();
    int c = read16();
    if (a != 0x0D || b != 1 || c != 1) {
        qWarning() << "DS2 unexpected first bytes =" << a << b << c;
        return false;
    }

    m_guid = readBytes();
    if (m_guid.size() != 36) {
        qWarning() << "DS2 guid unexpected length" << m_guid.size();
    } else {
        //qDebug() << "DS2 guid {" << m_guid << "}";
    }

    m_iv = readBytes();  // 96-bit IV
    e = readBytes();  // 128 bits, somehow seeds key
    if (m_iv.size() != 12 || e.size() != 16) {
        qWarning() << "DS2 IV,e sizes =" << m_iv.size() << e.size();
    } else {
        //qDebug() << "DS2 IV,e =" << m_iv.toHex() << e.toHex();
    }
    
    int f = read16();
    int g = read16();
    if (f != 0 || g != 1) {
        qWarning() << "DS2 unexpected middle bytes =" << f << g;
    }
    
    QByteArray h = readBytes();  // same per d,e pair, varies per machine
    QByteArray i = readBytes();  // same per d,e pair, varies per machine
    if (h.size() != 32 || i.size() != 16) {
        qWarning() << "DS2 h,i sizes =" << h.size() << i.size();
    } else {
        //qDebug() << "DS2 h,i =" << h.toHex() << i.toHex();
    }

    j = readBytes();  // same per d,e pair, does NOT vary per machine; possibly key or IV
    k = readBytes();  // same per d,e pair, does NOT vary per machine; possibly key or IV
    if (j.size() != 32 || k.size() != 16) {
        qWarning() << "DS2 j,k sizes =" << j.size() << k.size();
    } else {
        //qDebug() << "DS2 j,k =" << j.toHex() << k.toHex();
    }

    m_payload_tag = readBytes();
    if (m_payload_tag.size() != 16) {
        qWarning() << "DS2 payload tag size =" << m_payload_tag.size();
    } else {
        //qDebug() << "DS2 payload tag =" << m_payload_tag.toHex();
    }

    if (m_device.pos() != m_header_size) {
        qWarning() << "DS2 header size !=" << m_header_size;
    }
    return true;
}

int PRDS2File::read16()
{
    unsigned char data[2];
    int result;
    
    result = m_device.read((char*) data, sizeof(data));  // access the underlying data for the header
    if (result == sizeof(data)) {
        result = data[0] | (data[1] << 8);
    } else {
        result = 0;
    }
    return result;
}

QByteArray PRDS2File::readBytes()
{
    int length = read16();
    QByteArray result = m_device.read(length);  // access the underlying data for the header
    if (result.size() < length) {
        result.clear();
    }
    return result;
}


//********************************************************************************************


QMap<const char*,const char*> s_PRS1Series = {
    { "System One 60 Series", ":/icons/prs1_60s.png" },  // needs to come before following substring
    { "System One",           ":/icons/prs1.png" },
    { "C Series",             ":/icons/prs1vent.png" },
    { "DreamStation 2",       ":/icons/prds2.png" },  // needs to come before following substring
    { "DreamStation",         ":/icons/dreamstation.png" },
};

PRS1Loader::PRS1Loader()
{
#ifndef UNITTEST_MODE  // no QPixmap without a QGuiApplication
    for (auto & series : s_PRS1Series.keys()) {
        QString path = s_PRS1Series[series];
        m_pixmap_paths[series] = path;
        m_pixmaps[series] = QPixmap(path);
    }
#endif

    m_type = MT_CPAP;
}

PRS1Loader::~PRS1Loader()
{
}

bool isdigit(QChar c)
{
    if ((c >= '0') && (c <= '9')) { return true; }

    return false;
}


// Tests path to see if it has (what looks like) a valid PRS1 folder structure
// This is used both to detect newly inserted media and to decide which loader
// to use after the user selects a folder.
//
// TODO: Ideally there should be a way to handle the two scenarios slightly
// differently. In the latter case, it should clean up the selection and
// return the canonical path if it detects one, allowing us to remove the
// notification about selecting the root of the card. That kind of cleanup
// wouldn't be appropriate when scanning devices.
bool PRS1Loader::Detect(const QString & selectedPath)
{
    QString path = selectedPath;
    if (GetPSeriesPath(path).isEmpty()) {
        // Try up one level in case the user selected the P-Series folder within the SD card.
        path = QFileInfo(path).canonicalPath();
    }

    QStringList machines = FindMachinesOnCard(path);
    return !machines.isEmpty();
}

QString PRS1Loader::GetPSeriesPath(const QString & path)
{
    QString outpath = "";
    QDir root(path);
    QStringList dirs = root.entryList(QDir::NoDotAndDotDot | QDir::Dirs | QDir::Hidden | QDir::NoSymLinks);
    for (auto & dir : dirs) {
        // We've seen P-Series, P-SERIES, and p-series, so we need to search for the directory
        // in a way that won't break on a case-sensitive filesystem.
        if (dir.toUpper() == "P-SERIES") {
            outpath = path + QDir::separator() + dir;
            break;
        }
    }
    return outpath;
}

QStringList PRS1Loader::FindMachinesOnCard(const QString & cardPath)
{
    QStringList machinePaths;

    QString pseriesPath = this->GetPSeriesPath(cardPath);
    QDir pseries(pseriesPath);

    // If it contains a P-Series folder, it's a PRS1 SD card
    if (!pseriesPath.isEmpty() && pseries.exists()) {
        pseries.setFilter(QDir::NoDotAndDotDot | QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoSymLinks);
        pseries.setSorting(QDir::Name);
        QFileInfoList plist = pseries.entryInfoList();

        // Look for machine directories (containing a PROP.TXT or properties.txt)
        QFileInfoList propertyfiles;
        for (auto & pfi : plist) {
            if (pfi.isDir()) {
                QString machinePath = pfi.canonicalFilePath();
                QDir machineDir(machinePath);
                QFileInfoList mlist = machineDir.entryInfoList();
                for (auto & mfi : mlist) {
                    if (QDir::match("PROP*.TXT", mfi.fileName())) {
                        // Found a properties file, this is a machine folder
                        propertyfiles.append(mfi);
                    }
                    if (QDir::match("PROP.BIN", mfi.fileName())) {
                        // Found a DreamStation 2 properties file, this is a machine folder
                        propertyfiles.append(mfi);
                    }
                }
            }
        }

        // Sort machines from oldest to newest.
        std::sort(propertyfiles.begin(), propertyfiles.end(),
            [](const QFileInfo & a, const QFileInfo & b)
        {
            return a.lastModified() < b.lastModified();
        });

        for (auto & propertyfile : propertyfiles) {
            machinePaths.append(propertyfile.canonicalPath());
        }
    }

    return machinePaths;
}


void parseModel(MachineInfo & info, const QString & modelnum)
{
    info.modelnumber = modelnum;

    const char* name = s_PRS1ModelInfo.Name(modelnum);
    const char* series = nullptr;
    for (auto & s : s_PRS1Series.keys()) {
        if (QString(name).contains(s)) {
            series = s;
            break;
        }
    }
    if (series == nullptr) {
        if (modelnum != "100X100") {  // Bogus model number seen in empty C0/Clear0 directories.
            qWarning() << "unknown series for" << name << modelnum;
        }
        series = "unknown";
    }
    
    info.model = QObject::tr(name);
    info.series = series;
}

bool PRS1Loader::PeekProperties(const QString & filename, QHash<QString,QString> & props)
{
    const static QMap<QString,QString> s_longFieldNames = {
        // CF?
        { "SN", "SerialNumber" },
        { "MN", "ModelNumber" },
        { "PT", "ProductType" },
        { "DF", "DataFormat" },
        { "DFV", "DataFormatVersion" },
        { "F", "Family" },
        { "FV", "FamilyVersion" },
        { "SV", "SoftwareVersion" },
        { "FD", "FirstDate" },
        { "LD", "LastDate" },
        // SID?
        // SK?
        // TS?
        // DC?
        { "BK", "BasicKey" },
        { "DK", "DetailsKey" },
        { "EK", "ErrorKey" },
        { "FN", "PatientFolderNum" },  // most recent Pn directory
        { "PFN", "PatientFileNum" },   // number of files in the most recent Pn directory
        { "EFN", "EquipFileNum" },     // number of .004 files in the E directory
        { "DFN", "DFileNum" },         // number of .003 files in the D directory
        { "VC", "ValidCheck" },
    };
    
    QFile f(filename);
    if (!f.open(QFile::ReadOnly)) {
        return false;
    }

    RawDataFile* src;
    if (QFileInfo(f).suffix().toUpper() == "BIN") {
        // If it's a DS2 file, insert the DS2 wrapper to decode the chunk stream.
        PRDS2File* ds2 = new PRDS2File(f);
        if (!ds2->isValid()) {
            qWarning() << filename << "unable to decrypt";
            delete ds2;
            return false;
        }
        src = ds2;
        props["GUID"] = ds2->guid();
    } else {
        // Otherwise just use the file as input.
        src = new RawDataFile(f);
    }
    
    {
    QTextStream in(src);  // Scope this here so that it's torn down before we delete src below.
    
    do {
        QString line = in.readLine();
        QStringList pair = line.split("=");
        if (pair.size() != 2) {
            qWarning() << src->name() << "malformed line:" << line;
            QHashIterator<QString,QString> i(props);
            while (i.hasNext()) {
                i.next();
                qDebug() << i.key() << ":" << i.value();
            }
            break;
        }

        if (s_longFieldNames.contains(pair[0])) {
            pair[0] = s_longFieldNames[pair[0]];
        }
        if (pair[0] == "Family") {
            if (pair[1] == "xPAP") {
                pair[1] = "0";
            } else if (pair[1] == "Ventilator") {
                pair[1] = "3";
            }
        }
        props[pair[0]] = pair[1];
    } while (!in.atEnd());
    
    }

    delete src;

    return props.size() > 0;
}

bool PRS1Loader::PeekProperties(MachineInfo & info, const QString & filename)
{
    QHash<QString,QString> props;
    if (!PeekProperties(filename, props)) {
        return false;
    }
    QString modelnum;
    for (auto & key : props.keys()) {
        bool skip = false;

        if (key == "ModelNumber") {
            modelnum = props[key];
            skip = true;
        }
        if (key == "SerialNumber") {
            info.serial = props[key];
            skip = true;
        }
        if (key == "ProductType") {
            bool ok;
            props[key].toInt(&ok, 16);
            if (!ok) qWarning() << "ProductType" << props[key];
            skip = true;
        }
        if (skip) continue;

        info.properties[key] = props[key];
    };

    if (!modelnum.isEmpty()) {
        parseModel(info, modelnum);
    } else {
        qWarning() << "missing model number" << filename;
    }

    return true;
}


MachineInfo PRS1Loader::PeekInfo(const QString & path)
{
    QStringList machines = FindMachinesOnCard(path);
    if (machines.isEmpty()) {
        return MachineInfo();
    }

    // Present information about the newest machine on the card.
    QString newpath = machines.last();
    
    MachineInfo info = newInfo();
    if (!PeekProperties(info, newpath+"/properties.txt")) {
        if (!PeekProperties(info, newpath+"/PROP.TXT")) {
            // Detect (unsupported) DreamStation 2
            QString filepath(newpath + "/PROP.BIN");
            if (!PeekProperties(info, filepath)) {
                qWarning() << "No properties file found in" << newpath;
            }
        }
    }
    return info;
}


int PRS1Loader::Open(const QString & selectedPath)
{
    QString path = selectedPath;
    if (GetPSeriesPath(path).isEmpty()) {
        // Try up one level in case the user selected the P-Series folder within the SD card.
        path = QFileInfo(path).canonicalPath();
    }

    QStringList machines = FindMachinesOnCard(path);
    // Return an error if no machines were found.
    if (machines.isEmpty()) {
        qDebug() << "No PRS1 machines found at" << path;
        return -1;
    }

    // Import each machine, from oldest to newest.
    // TODO: Loaders should return the set of machines during detection, so that Open() will
    // open a unique device, instead of surprising the user.
    int c = 0;
    bool failures = false;
    for (auto & machinePath : machines) {
        if (m_ctx == nullptr) {
            qWarning() << "PRS1Loader::Open() called without a valid m_ctx object present";
            return 0;
        }
        int imported = OpenMachine(machinePath);
        if (imported >= 0) {  // don't let errors < 0 suppress subsequent successes
            c += imported;
        } else {
            failures = true;
        }
        m_ctx->FlushUnexpectedMessages();
    }
    if (c == 0 && failures) {
        // report an error when there were failures and no successess
        c = -1;
    }
    return c;
}


int PRS1Loader::OpenMachine(const QString & path)
{
    Q_ASSERT(m_ctx);

    qDebug() << "Opening PRS1 " << path;
    QDir dir(path);

    if (!dir.exists() || (!dir.isReadable())) {
        return 0;
    }
    m_abort = false;

    emit updateMessage(QObject::tr("Getting Ready..."));
    QCoreApplication::processEvents();

    emit setProgressValue(0);

    QStringList paths;
    QString propertyfile;
    int sessionid_base;
    sessionid_base = FindSessionDirsAndProperties(path, paths, propertyfile);

    bool supported = CreateMachineFromProperties(propertyfile);
    if (!supported) {
        // Device is unsupported.
        return -1;
    }

    emit updateMessage(QObject::tr("Backing Up Files..."));
    QCoreApplication::processEvents();

    QString backupPath = context()->GetBackupPath() + path.section("/", -2);

    if (QDir::cleanPath(path).compare(QDir::cleanPath(backupPath)) != 0) {
        copyPath(path, backupPath);
    }

    emit updateMessage(QObject::tr("Scanning Files..."));
    QCoreApplication::processEvents();

    // Walk through the files and create an import task for each logical session.
    ScanFiles(paths, sessionid_base);

    int tasks = countTasks();

    emit updateMessage(QObject::tr("Importing Sessions..."));
    QCoreApplication::processEvents();

    runTasks(AppSetting->multithreading());

    return tasks;
}


int PRS1Loader::FindSessionDirsAndProperties(const QString & path, QStringList & paths, QString & propertyfile)
{
    QDir dir(path);
    dir.setFilter(QDir::NoDotAndDotDot | QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoSymLinks);
    dir.setSorting(QDir::Name);
    QFileInfoList flist = dir.entryInfoList();

    QString filename;

    int sessionid_base = 10;

    for (int i = 0; i < flist.size(); i++) {
        QFileInfo fi = flist.at(i);
        filename = fi.fileName();

        if (fi.isDir()) {
            if ((filename[0].toLower() == 'p') && (isdigit(filename[1]))) {
                // p0, p1, p2.. etc.. folders contain the session data
                paths.push_back(fi.canonicalFilePath());
            } else if (filename.toLower() == "e") {
                // Error files..
                // Reminder: I have been given some info about these. should check it over.
            }
        } else if (filename.compare("properties.txt",Qt::CaseInsensitive) == 0) {
            propertyfile = fi.canonicalFilePath();
        } else if (filename.compare("PROP.TXT",Qt::CaseInsensitive) == 0) {
            sessionid_base = 16;
            propertyfile = fi.canonicalFilePath();
        } else if (filename.compare("PROP.BIN", Qt::CaseInsensitive) == 0) {
            sessionid_base = 16;
            propertyfile = fi.canonicalFilePath();
        }
    }
    return sessionid_base;
}


bool PRS1Loader::CreateMachineFromProperties(QString propertyfile)
{
    MachineInfo info = newInfo();
    QHash<QString,QString> props;
    if (!PeekProperties(propertyfile, props) || !s_PRS1ModelInfo.IsSupported(props)) {
        if (props.contains("ModelNumber")) {
            int family, familyVersion;
            getVersionFromProps(props, family, familyVersion);
            QString model_number = props["ModelNumber"];
            qWarning().noquote() << "Model" << model_number << QString("(F%1V%2)").arg(family).arg(familyVersion) << "unsupported.";
            info.modelnumber = QObject::tr("model %1").arg(model_number);
        } else if (propertyfile.endsWith("PROP.BIN")) {
            // TODO: If we end up releasing without support for non-default keys,
            // add a loaderSpecificAlert(QString & message, bool deferred=false) signal
            // and use that instead of telling the user that the DS2 is entirely unsupported.
            qWarning() << "DreamStation 2 not using default keys:" << propertyfile;
            info.modelnumber = QObject::tr("DreamStation 2");
        } else {
            qWarning() << "Unable to identify model or series!";
            info.modelnumber = QObject::tr("unknown model");
        }
        emit deviceIsUnsupported(info);
        return false;
    }

    // Have a peek first to get the model number.
    PeekProperties(info, propertyfile);

    if (s_PRS1ModelInfo.IsBrick(info.modelnumber)) {
        emit deviceReportsUsageOnly(info);
    }

    // Which is needed to get the right machine record..
    m_ctx->CreateMachineFromInfo(info);

    if (!s_PRS1ModelInfo.IsTested(props)) {
        qDebug() << info.modelnumber << "untested";
        emit deviceIsUntested(info);
    }

    return true;
}


static QString relativePath(const QString & inpath)
{
    QStringList pathlist = QDir::toNativeSeparators(inpath).split(QDir::separator(), QString::SkipEmptyParts);
    QString relative = pathlist.mid(pathlist.size()-3).join(QDir::separator());
    return relative;
}

static bool chunksIdentical(const PRS1DataChunk* a, const PRS1DataChunk* b)
{
    return (a->hash() == b->hash());
}

static QString chunkComparison(const PRS1DataChunk* a, const PRS1DataChunk* b)
{
    return QString("Session %1 in %2 @ %3 %4 %5 @ %6, skipping")
            .arg(a->sessionid)
            .arg(relativePath(a->m_path)).arg(a->m_filepos)
            .arg(chunksIdentical(a, b) ? "is identical to" : "differs from")
            .arg(relativePath(b->m_path)).arg(b->m_filepos);

}

void PRS1Loader::ScanFiles(const QStringList & paths, int sessionid_base)
{
    Q_ASSERT(m_ctx);
    SessionID sid;
    long ext;

    QDir dir;
    dir.setFilter(QDir::NoDotAndDotDot | QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoSymLinks);
    dir.setSorting(QDir::Name);

    int size = paths.size();

    sesstasks.clear();
    new_sessions.clear(); // this hash is used by OpenFile


    PRS1Import * task = nullptr;
    // Note, I have observed p0/p1/etc folders containing duplicates session files (in Robin Sanders data.)

    QDateTime datetime;

    qint64 ignoreBefore = m_ctx->IgnoreSessionsOlderThan().toMSecsSinceEpoch()/1000;
    bool ignoreOldSessions = m_ctx->ShouldIgnoreOldSessions();
    QSet<SessionID> skipped;

    // for each p0/p1/p2/etc... folder
    for (int p=0; p < size; ++p) {
        dir.setPath(paths.at(p));

        if (!dir.exists() || !dir.isReadable()) {
            qWarning() << dir.canonicalPath() << "can't read directory";
            continue;
        }

        QFileInfoList flist = dir.entryInfoList();

        // Scan for individual session files
        for (int i = 0; i < flist.size(); i++) {
#ifndef UNITTEST_MODE
            QCoreApplication::processEvents();
#endif
            if (isAborted()) {
                qDebug() << "received abort signal";
                break;
            }
            QFileInfo fi = flist.at(i);
            QString path = fi.canonicalFilePath();
            bool ok;

            if (fi.fileName() == ".DS_Store") {
                continue;
            }

            QString ext_s = fi.fileName().section(".", -1);
            if (ext_s.toUpper().startsWith("B")) {  // .B01, .B02, etc.
                ext_s = ext_s.mid(1);
            }
            ext = ext_s.toInt(&ok);
            if (!ok) {
                // not a numerical extension
                qInfo() << path << "unexpected filename";
                continue;
            }

            QString session_s = fi.fileName().section(".", 0, -2);
            sid = session_s.toInt(&ok, sessionid_base);
            if (!ok) {
                // not a numerical session ID
                qInfo() << path << "unexpected filename";
                continue;
            }

            // TODO: BUG: This isn't right, since files can have multiple session
            // chunks, which might not correspond to the filename. But before we can
            // fix this we need to come up with a reasonably fast way to filter previously
            // imported files without re-reading all of them.
            if (context()->SessionExists(sid)) {
                // Skip already imported session
                // TODO: Consider reinstating this debug statement if/when we scan only new/changed files.
                //qDebug() << path << "session already exists, skipping" << sid;
                continue;
            }

            if ((ext == 5) || (ext == 6)) {
                if (skipped.contains(sid)) {
                    // We don't know the timestamp until the file is parsed, which we only do for
                    // waveform data at import (after scanning) since it's so large. If we relied
                    // solely on the chunks' timestamps at that point, we'd get half of an otherwise
                    // skipped session (the half after midnight).
                    //
                    // So we skip the entire file here based on the session's other data.
                    continue;
                }

                // Waveform files aren't grouped... so we just want to add the filename for later
                QHash<SessionID, PRS1Import *>::iterator it = sesstasks.find(sid);
                if (it != sesstasks.end()) {
                    task = it.value();
                } else {
                    // Should probably check if session already imported has this data missing..

                    // Create the group if we see it first..
                    task = new PRS1Import(this, sid, sessionid_base);
                    sesstasks[sid] = task;
                    queTask(task);
                }

                if (ext == 5) {
                    // Occasionally waveforms in a session can be split into multiple files.
                    //
                    // This seems to happen when the machine begins writing the waveform file
                    // before realizing that it will hit its 500-file-per-directory limit
                    // for the remaining session files, at which point it appears to write
                    // the rest of the waveform data along with the summary and event files
                    // in the next directory.
                    //
                    // All samples exhibiting this behavior are DreamStations.
                    task->m_wavefiles.append(fi.canonicalFilePath());
                } else if (ext == 6) {
                    // Oximetry data can also be split into multiple files, see waveform
                    // comment above.
                    task->m_oxifiles.append(fi.canonicalFilePath());
                }

                continue;
            }

            // Parse the data chunks and read the files..
            QList<PRS1DataChunk *> Chunks = ParseFile(fi.canonicalFilePath());

            for (int i=0; i < Chunks.size(); ++i) {
                if (isAborted()) {
                    qDebug() << "received abort signal 2";
                    break;
                }

                PRS1DataChunk * chunk = Chunks.at(i);

                SessionID chunk_sid = chunk->sessionid;
                if (i == 0 && chunk_sid != sid) {  // log session ID mismatches
                    // This appears to be benign, probably when a card is out of the machine one night and
                    // then inserted in the morning. It writes out all of the still-in-memory summaries and
                    // events up through the last night (and no waveform data).
                    //
                    // This differs from the first time a card is inserted, because in that case the filename
                    // *is* equal to the first session contained within it, and then filenames for the
                    // remaining sessions contained in that file are skipped.
                    //
                    // Because the card was present and previous sessions were written with their filenames,
                    // the first available filename isn't the first session contained in the file.
                    //qDebug() << fi.canonicalFilePath() << "first session is" << chunk_sid << "instead of" << sid;
                }
                if (context()->SessionExists(chunk_sid)) {
                    qDebug() << path << "session already imported, skipping" << sid << chunk_sid;
                    delete chunk;
                    continue;
                }
                if (ignoreOldSessions && chunk->timestamp < ignoreBefore) {
                    qDebug().noquote() << relativePath(path) << "skipping session" << chunk_sid << ":"
                        << QDateTime::fromMSecsSinceEpoch(chunk->timestamp*1000).toString() << "older than"
                        << QDateTime::fromMSecsSinceEpoch(ignoreBefore*1000).toString();
                    skipped += chunk_sid;
                    delete chunk;
                    continue;
                }

                task = nullptr;
                QHash<SessionID, PRS1Import *>::iterator it = sesstasks.find(chunk_sid);
                if (it != sesstasks.end()) {
                    task = it.value();
                } else {
                    task = new PRS1Import(this, chunk_sid, sessionid_base);
                    sesstasks[chunk_sid] = task;
                    // save a loop an que this now
                    queTask(task);
                }
                switch (ext) {
                case 0:
                    if (task->compliance) {
                        if (chunksIdentical(chunk, task->summary)) {
                            // Never seen identical compliance chunks, so keep logging this for now.
                            qDebug() << chunkComparison(chunk, task->summary);
                        } else {
                            qWarning() << chunkComparison(chunk, task->summary);
                        }
                        delete chunk;
                        continue; // (skipping to avoid duplicates)
                    }
                    task->compliance = chunk;
                    break;
                case 1:
                    if (task->summary) {
                        if (chunksIdentical(chunk, task->summary)) {
                            // This seems to be benign. It happens most often when a single file contains
                            // a bunch of chunks and subsequent files each contain a single chunk that was
                            // already covered by the first file. It also sometimes happens with entirely
                            // duplicate files between e.g. a P1 and P0 directory.
                            //
                            // It's common enough that we don't emit a message about it by default.
                            //qDebug() << chunkComparison(chunk, task->summary);
                        } else {
                            // Warn about any non-identical duplicate session IDs.
                            //
                            // This seems to happen with F5V1 slice 8, which is the only slice in a session,
                            // and which doesn't update the session ID, so the following slice 7 session
                            // (which can be hours later) has the same session ID. Neither affects import.
                            qWarning() << chunkComparison(chunk, task->summary);
                        }
                        delete chunk;
                        continue;
                    }
                    task->summary = chunk;
                    break;
                case 2:
                    if (task->m_event_chunks.count() > 0) {
                        PRS1DataChunk* previous;
                        if (chunk->family == 3 && chunk->familyVersion <= 3) {
                            // F3V0 and F3V3 events are formatted as waveforms, with one chunk per mask-on slice,
                            // and thus multiple chunks per session.
                            previous = task->m_event_chunks[chunk->timestamp];
                            if (previous != nullptr) {
                                // Skip any chunks with identical timestamps.
                                qWarning() << chunkComparison(chunk, previous);
                                delete chunk;
                                continue;
                            }
                            // fall through to add the new chunk
                        } else {
                            // Nothing else should have multiple event chunks per session.
                            previous = task->m_event_chunks.first();
                            if (chunksIdentical(chunk, previous)) {
                                // See comment above regarding identical summary chunks.
                                //qDebug() << chunkComparison(chunk, previous);
                            } else {
                                qWarning() << chunkComparison(chunk, previous);
                            }
                            delete chunk;
                            continue;
                        }
                    }
                    task->m_event_chunks[chunk->timestamp] = chunk;
                    break;
                default:
                    qWarning() << path << "unexpected file";
                    break;
                }
            }
        }
        if (isAborted()) {
            qDebug() << "received abort signal 3";
            break;
        }
    }
}


// The set of PRS1 "on-demand" channels that only get created on import if the session
// contains events of that type.  Any channels not on this list always get created if
// they're reported/supported by the parser.
static const QVector<PRS1ParsedEventType> PRS1OnDemandChannels =
{
    PRS1TimedBreathEvent::TYPE,
    PRS1PressurePulseEvent::TYPE,
    
    // Pressure initialized on-demand for F0 due to the possibility of bilevel vs. single pressure.
    PRS1PressureSetEvent::TYPE,
    PRS1IPAPSetEvent::TYPE,
    PRS1EPAPSetEvent::TYPE,
    
    // Pressure average initialized on-demand for F0 due to the different semantics of bilevel vs. single pressure.
    PRS1PressureAverageEvent::TYPE,
    PRS1FlexPressureAverageEvent::TYPE,
};

// The set of "non-slice" channels are independent of mask-on slices, i.e. they
// are continuously reported and charted regardless of whether the mask is on.
static const QSet<PRS1ParsedEventType> PRS1NonSliceChannels =
{
    PRS1PressureSetEvent::TYPE,
    PRS1IPAPSetEvent::TYPE,
    PRS1EPAPSetEvent::TYPE,
    PRS1SnoresAtPressureEvent::TYPE,
};

// The channel ID (referenced by pointer because their values aren't initialized
// prior to runtime) to which a given PRS1 event should be added.  Events with
// no channel IDs are silently dropped, and events with more than one channel ID
// must be handled specially.
static const QHash<PRS1ParsedEventType,QVector<ChannelID*>> PRS1ImportChannelMap =
{
    { PRS1ClearAirwayEvent::TYPE,       { &CPAP_ClearAirway } },
    { PRS1ObstructiveApneaEvent::TYPE,  { &CPAP_Obstructive } },
    { PRS1HypopneaEvent::TYPE,          { &CPAP_Hypopnea } },
    { PRS1FlowLimitationEvent::TYPE,    { &CPAP_FlowLimit } },
    { PRS1SnoreEvent::TYPE,             { &CPAP_Snore, &CPAP_VSnore2 } },  // VSnore2 is calculated from snore count, used to flag nonzero intervals on overview
    { PRS1VibratorySnoreEvent::TYPE,    { &CPAP_VSnore } },
    { PRS1RERAEvent::TYPE,              { &CPAP_RERA } },

    { PRS1PeriodicBreathingEvent::TYPE, { &CPAP_PB } },
    { PRS1LargeLeakEvent::TYPE,         { &CPAP_LargeLeak } },
    { PRS1TotalLeakEvent::TYPE,         { &CPAP_LeakTotal } },
    { PRS1LeakEvent::TYPE,              { &CPAP_Leak } },

    { PRS1RespiratoryRateEvent::TYPE,   { &CPAP_RespRate } },
    { PRS1TidalVolumeEvent::TYPE,       { &CPAP_TidalVolume } },
    { PRS1MinuteVentilationEvent::TYPE, { &CPAP_MinuteVent } },
    { PRS1PatientTriggeredBreathsEvent::TYPE, { &CPAP_PTB } },
    { PRS1TimedBreathEvent::TYPE,       { &PRS1_TimedBreath } },
    { PRS1FlowRateEvent::TYPE,          { &PRS1_PeakFlow } },  // Only reported by F3V0 and F3V3  // TODO: should this stat be calculated from flow waveforms on other models?

    { PRS1PressureSetEvent::TYPE,       { &CPAP_PressureSet } },
    { PRS1IPAPSetEvent::TYPE,           { &CPAP_IPAPSet, &CPAP_PS } },  // PS is calculated from IPAPset and EPAPset when both are supported (F0) TODO: Should this be a separate channel since it's not a 2-minute average?
    { PRS1EPAPSetEvent::TYPE,           { &CPAP_EPAPSet } },            // EPAPset is supported on F5 without any corresponding IPAPset, so it shouldn't always create a PS channel
    { PRS1PressureAverageEvent::TYPE,   { &CPAP_Pressure } },           // This is the time-weighted average pressure in bilevel modes.
    { PRS1FlexPressureAverageEvent::TYPE, { &CPAP_EPAP } },             // This is effectively EPAP due to Flex reduced pressure in single-pressure modes.
    { PRS1IPAPAverageEvent::TYPE,       { &CPAP_IPAP } },
    { PRS1EPAPAverageEvent::TYPE,       { &CPAP_EPAP, &CPAP_PS } },     // PS is calculated from IPAP and EPAP averages (F3 and F5)
    { PRS1IPAPLowEvent::TYPE,           { &CPAP_IPAPLo } },
    { PRS1IPAPHighEvent::TYPE,          { &CPAP_IPAPHi } },

    { PRS1Test1Event::TYPE,             { &CPAP_Test1 } },  // ??? F3V6
    { PRS1Test2Event::TYPE,             { &CPAP_Test2 } },  // ??? F3V6

    { PRS1PressurePulseEvent::TYPE,     { &CPAP_PressurePulse } },
    { PRS1ApneaAlarmEvent::TYPE,        { /* Not imported */ } },
    { PRS1SnoresAtPressureEvent::TYPE,  { /* Not imported */ } },
    { PRS1AutoPressureSetEvent::TYPE,   { /* Not imported */ } },
    { PRS1VariableBreathingEvent::TYPE, { &PRS1_VariableBreathing } },  // UNCONFIRMED
    
    { PRS1HypopneaCount::TYPE,          { &CPAP_Hypopnea } },     // F3V3 only, generates individual events on import
    { PRS1ObstructiveApneaCount::TYPE,  { &CPAP_Obstructive } },  // F3V3 only, generates individual events on import
    { PRS1ClearAirwayCount::TYPE,       { &CPAP_ClearAirway } },  // F3V3 only, generates individual events on import
};

//********************************************************************************************


PRS1Import::~PRS1Import()
{
    delete compliance;
    delete summary;
    for (auto & e : m_event_chunks.values()) {
        delete e;
    }
    for (int i=0;i < waveforms.size(); ++i) {
        delete waveforms.at(i);
    }
    for (auto & c : oximetry) {
        delete c;
    }
}


void PRS1Import::CreateEventChannels(const PRS1DataChunk* chunk)
{
    const QVector<PRS1ParsedEventType> & supported = GetSupportedEvents(chunk);

    // Generate the list of channels created by non-slice events for this machine.
    // We can't just use the full list of non-slice events, since on some machines
    // PS is generated by slice events (EPAP/IPAP average).
    // TODO: convert supported to QSet and clean this up.
    QSet<PRS1ParsedEventType> supportedNonSliceEvents = QSet<PRS1ParsedEventType>::fromList(QList<PRS1ParsedEventType>::fromVector(supported));
    supportedNonSliceEvents.intersect(PRS1NonSliceChannels);
    QSet<ChannelID> supportedNonSliceChannels;
    for (auto & e : supportedNonSliceEvents) {
        for (auto & pChannelID : PRS1ImportChannelMap[e]) {
            supportedNonSliceChannels += *pChannelID;
        }
    }

    // Clear channels to prepare for a new slice, except for channels created by
    // non-slice events.
    for (auto & c : m_importChannels.keys()) {
        if (supportedNonSliceChannels.contains(c) == false) {
            m_importChannels.remove(c);
        }
    }
    
    // Create all supported channels (except for on-demand ones that only get created if an event appears)
    for (auto & e : supported) {
        if (!PRS1OnDemandChannels.contains(e)) {
            for (auto & pChannelID : PRS1ImportChannelMap[e]) {
                GetImportChannel(*pChannelID);
            }
        }
    }
}


EventList* PRS1Import::GetImportChannel(ChannelID channel)
{
    if (!channel) {
        qCritical() << this->sessionid << "channel in import table has not been added to schema!";
    }
    EventList* C = m_importChannels[channel];
    if (C == nullptr) {
        C = session->AddEventList(channel, EVL_Event);
        Q_ASSERT(C);  // Once upon a time AddEventList could return nullptr, but not any more.
        m_importChannels[channel] = C;
    }
    return C;
}


void PRS1Import::AddEvent(ChannelID channel, qint64 t, float value, float gain)
{
    EventList* C = GetImportChannel(channel);
    Q_ASSERT(C);
    if (C->count() == 0) {
        // Initialize the gain (here, since required channels are created with default gain).
        C->setGain(gain);
    } else {
        // Any change in gain is a programming error.
        if (gain != C->gain()) {
            qWarning() << "gain mismatch for channel" << channel << "at" << ts(t);
        }
    }
    
    // Add the event
    C->AddEvent(t, value, gain);
}


bool PRS1Import::UpdateCurrentSlice(PRS1DataChunk* chunk, qint64 t)
{
    bool updated = false;
    
    if (!m_currentSliceInitialized) {
        m_currentSliceInitialized = true;
        m_currentSlice = m_slices.constBegin();
        m_lastIntervalEvents.clear();  // there was no previous slice, so there are no pending end-of-slice events
        m_lastIntervalEnd = 0;
        updated = true;
    }

    // Update the slice iterator to point to the mask-on slice encompassing time t.
    while ((*m_currentSlice).status != MaskOn || t > (*m_currentSlice).end) {
        m_currentSlice++;
        updated = true;
        if (m_currentSlice == m_slices.constEnd()) {
            qWarning() << sessionid << "Events after last mask-on slice?";
            m_currentSlice--;
            break;
        }
    }
    
    if (updated) {
        // Write out any pending end-of-slice events.
        FinishSlice();
    }
    
    if (updated && (*m_currentSlice).status == MaskOn) {
        // Set the interval start times based on the new slice's start time.
        m_statIntervalStart = 0;
        StartNewInterval((*m_currentSlice).start);

        // Create a new eventlist for this new slice, to allow for a gap in the data between slices.
        CreateEventChannels(chunk);
    }
    
    return updated;
}


void PRS1Import::FinishSlice()
{
    qint64 t = m_lastIntervalEnd;  // end of the slice (at least of its interval data)

    // If the most recently recorded interval stats aren't at the end of the slice,
    // import additional events marking the end of the data.
    if (t != m_prevIntervalStart) {
        // Make sure to use the same pressure used to import the original events,
        // otherwise calculated channels (such as PS or LEAK) will be wrong.
        EventDataType orig_pressure = m_currentPressure;
        m_currentPressure = m_intervalPressure;

        // Import duplicates of each event with the end-of-slice timestamp.
        for (auto & e : m_lastIntervalEvents) {
            ImportEvent(t, e);
        }

        // Restore the current pressure.
        m_currentPressure = orig_pressure;
    }
    m_lastIntervalEvents.clear();
}


void PRS1Import::StartNewInterval(qint64 t)
{
    if (t == m_prevIntervalStart) {
        qWarning() << sessionid << "Multiple zero-length intervals at end of slice?";
    }
    m_prevIntervalStart = m_statIntervalStart;
    m_statIntervalStart = t;
}


bool PRS1Import::IsIntervalEvent(PRS1ParsedEvent* e)
{
    bool intervalEvent = false;

    // Statistical timestamps are reported at the end of a (generally) 2-minute
    // interval, rather than the start time that OSCAR expects for its imported
    // events.  (When a session or slice ends, there will be a shorter interval,
    // the previous statistics to the end of the session/slice.)
    switch (e->m_type) {
        case PRS1PressureAverageEvent::TYPE:
        case PRS1FlexPressureAverageEvent::TYPE:
        case PRS1IPAPAverageEvent::TYPE:
        case PRS1IPAPLowEvent::TYPE:
        case PRS1IPAPHighEvent::TYPE:
        case PRS1EPAPAverageEvent::TYPE:
        case PRS1TotalLeakEvent::TYPE:
        case PRS1LeakEvent::TYPE:
        case PRS1RespiratoryRateEvent::TYPE:
        case PRS1PatientTriggeredBreathsEvent::TYPE:
        case PRS1MinuteVentilationEvent::TYPE:
        case PRS1TidalVolumeEvent::TYPE:
        case PRS1FlowRateEvent::TYPE:
        case PRS1Test1Event::TYPE:
        case PRS1Test2Event::TYPE:
        case PRS1SnoreEvent::TYPE:
        case PRS1HypopneaCount::TYPE:
        case PRS1ClearAirwayCount::TYPE:
        case PRS1ObstructiveApneaCount::TYPE:
            intervalEvent = true;
            break;
        default:
            break;
    }
    
    return intervalEvent;
}


bool PRS1Import::ImportEventChunk(PRS1DataChunk* event)
{
    m_currentPressure=0;

    const QVector<PRS1ParsedEventType> & supported = GetSupportedEvents(event);
    
    // Calculate PS from IPAP/EPAP set events only when both are supported. This includes F0, but excludes
    // F5, which only reports EPAP set events, but both IPAP/EPAP average, from which PS will be calculated.
    m_calcPSfromSet = supported.contains(PRS1IPAPSetEvent::TYPE) && supported.contains(PRS1EPAPSetEvent::TYPE);
    
    qint64 t = qint64(event->timestamp) * 1000L;
    if (session->first() == 0) {
        qWarning() << sessionid << "Start time not set by summary?";
    } else if (t < session->first()) {
        qWarning() << sessionid << "Events start before summary?";
    }

    bool ok;
    ok = event->ParseEvents();
    
    // Set up the (possibly initial) slice based on the chunk's starting timestamp.
    UpdateCurrentSlice(event, t);

    for (int i=0; i < event->m_parsedData.count(); i++) {
        PRS1ParsedEvent* e = event->m_parsedData.at(i);
        t = qint64(event->timestamp + e->m_start) * 1000L;

        // Skip unknown events with no timestamp
        if (e->m_type == PRS1UnknownDataEvent::TYPE) {
            continue;
        }
        
        // Skip zero-length PB or LL or VB events
        if ((e->m_type == PRS1PeriodicBreathingEvent::TYPE || e->m_type == PRS1LargeLeakEvent::TYPE || e->m_type == PRS1VariableBreathingEvent::TYPE) &&
            (e->m_duration == 0)) {
            // LL occasionally appear about a minute before a new mask-on slice
            // begins, when the previous mask-on slice ended with a large leak.
            // This probably indicates the end of LL and beginning
            // of breath detection, but we don't get any real data until mask-on.
            //
            // It has also happened once in a similar scenario for PB and VB, even when
            // the two mask-on slices are in different sessions!
            continue;
        }
        
        if (e->m_type == PRS1IntervalBoundaryEvent::TYPE) {
            StartNewInterval(t);
            continue;  // these internal pseudo-events don't get imported
        }
        
        bool intervalEvent = IsIntervalEvent(e);
        qint64 interval_end_t = 0;
        if (intervalEvent) {
            // Deal with statistics that are reported at the end of an interval, but which need to be imported
            // at the start of the interval.

            if (event->family == 3 && event->familyVersion <= 3) {
                // In F3V0 and F3V3, each slice has its own chunk, so the initial call to UpdateCurrentSlice()
                // for this chunk is all that's needed.
                //
                // We can't just call it again here for simplicity, since the timestamps of F3V3 stat events
                // can go past the end of the slice.
            } else {
                // For all other machines, the event's time stamp will be within bounds of its slice, so
                // we can use it to find the current slice.
                UpdateCurrentSlice(event, t);
            }
            // Clamp this interval's end time to the end of the slice.
            interval_end_t = min(t, (*m_currentSlice).end);
            // Set this event's timestamp as the start of the interval, since that what OSCAR assumes.
            t = m_statIntervalStart;
            // TODO: ideally we would also set the duration of the event, but OSCAR doesn't have any notion of that yet.
        } else {
            // Advance the slice if needed for the regular event's timestamp.
            if (!PRS1NonSliceChannels.contains(e->m_type)) {
                UpdateCurrentSlice(event, t);
            }
        }

        // Sanity check: warn if a (non-slice) event is earlier than the current mask-on slice
        if (t < (*m_currentSlice).start && (*m_currentSlice).status == MaskOn) {
            if (!PRS1NonSliceChannels.contains(e->m_type)) {
                // LL and VB at the beginning of a mask-on session sometimes start 1 second early,
                // so suppress that warning.
                if ((*m_currentSlice).start - t > 1000 || (e->m_type != PRS1LargeLeakEvent::TYPE && e->m_type != PRS1VariableBreathingEvent::TYPE)) {
                    qWarning() << sessionid << "Event" << e->m_type << "before mask-on slice:" << ts(t);
                }
            }
        }

        // Import the event.
        switch (e->m_type) {
            // F3V3 doesn't have individual HY/CA/OA events, only counts every 2 minutes, where
            // nonzero counts show up as overview flags. Currently OSCAR doesn't have a way to
            // chart those numeric statistics, so we generate events based on the count.
            //
            // TODO: This (and VS2) would probably be better handled as numeric charts only,
            // along with enhancing overview flags to be drawn when channels have nonzero values,
            // instead of the fictitious "events" that are currently generated.
            case PRS1HypopneaCount::TYPE:
            case PRS1ClearAirwayCount::TYPE:
            case PRS1ObstructiveApneaCount::TYPE:
                // Make sure PRS1ClearAirwayEvent/etc. isn't supported before generating events from counts.
                CHECK_VALUE(supported.contains(PRS1HypopneaEvent::TYPE), false);
                CHECK_VALUE(supported.contains(PRS1ClearAirwayEvent::TYPE), false);
                CHECK_VALUE(supported.contains(PRS1ObstructiveApneaEvent::TYPE), false);

                // Divide each count into events evenly spaced over the interval.
                // NOTE: This is slightly fictional, but there's no waveform data for F3V3, so it won't
                // incorrectly associate specific events with specific flow or pressure events.
                if (e->m_value > 0) {
                    qint64 blockduration = interval_end_t - m_statIntervalStart;
                    qint64 div = blockduration / e->m_value;
                    qint64 tt = t;
                    PRS1ParsedDurationEvent ee(e->m_type, t, 0);
                    for (int i=0; i < e->m_value; ++i) {
                        ImportEvent(tt, &ee);
                        tt += div;
                    }
                }
                
                // TODO: Consider whether to have a numeric channel for HY/CA/OA that gets charted like VS does,
                // in which case we can fall through.
                break;
                
            default:
                ImportEvent(t, e);

                // Cache the most recently imported interval events so that we can import duplicate end-of-slice events if needed.
                // We can't write them here because we don't yet know if they're the last in the slice.
                if (intervalEvent) {
                    // Reset the list of pending events when we encounter a stat event in a new interval.
                    //
                    // This logic has grown sufficiently complex that it may eventually be worth encapsulating
                    // each batch of parsed interval events into a composite interval event when parsing,
                    // rather than requiring timestamp-based gymnastics to infer that structure on import.
                    if (m_lastIntervalEnd != interval_end_t) {
                        m_lastIntervalEvents.clear();
                        m_lastIntervalEnd = interval_end_t;
                        m_intervalPressure = m_currentPressure;
                    }
                    // The events need to be in order so that any dynamically calculated channels (such as PS) are properly computed.
                    m_lastIntervalEvents.append(e);
                }
                break;
        }
    }
    
    // Write out any pending end-of-slice events.
    FinishSlice();

    if (!ok) {
        return false;
    }

    // TODO: This needs to be special-cased for F3V0 and F3V3 due to their weird interval-based event format
    // until there's a way for its parser to correctly set the timestamps for truncated
    // intervals in sessions that don't end on a 2-minute boundary.
    if (!(event->family == 3 && event->familyVersion <= 3)) {
        // If the last event has a non-zero duration, t will not reflect the full duration of the chunk, so update it.
        t = qint64(event->timestamp + event->duration) * 1000L;
        if (session->last() == 0) {
            qWarning() << sessionid << "End time not set by summary?";
        } else if (t > session->last()) {
            // This has only been seen in two instances:
            // 1. Once with corrupted data, in which the summary and event files each contained
            //    multiple conflicting sessions (all brief) with the same ID.
            // 2. On one 500G110, multiple PRS1PressureSetEvents appear after the end of the session,
            //    across roughtly two dozen sessions. These seem to be discarded on official reports,
            //    see ImportEvent() below.
            qWarning() << sessionid << "Events continue after summary?";
        }
        // Events can end before the session if the mask was off before the equipment turned off.
    }

    return true;
}


void PRS1Import::ImportEvent(qint64 t, PRS1ParsedEvent* e)
{
    qint64 duration;

    // TODO: Filter out duplicate/overlapping PB and RE events.
    //
    // These actually get reported by the machines, but they cause "unordered time" warnings
    // and they throw off the session statistics. Even official reports show the wrong stats,
    // for example counting each of 3 duplicate PBs towards the total time in PB.
    //
    // It's not clear whether filtering can reasonably be done here or whether it needs
    // to be done in ImportEventChunk.
    
    const QVector<ChannelID*> & channels = PRS1ImportChannelMap[e->m_type];
    ChannelID channel = NoChannel, PS, VS2;
    if (channels.count() > 0) {
        channel = *channels.at(0);
    }
    
    switch (e->m_type) {
        case PRS1PressureSetEvent::TYPE:  // currentPressure is used to calculate unintentional leak, not just PS
            // TODO: These have sometimes been observed with t > session->last() on a 500G110.
            // Official reports seem to discard such events, OSCAR currently doesn't.
            // Test this more thoroughly before changing behavior here.
            // fall through
        case PRS1IPAPSetEvent::TYPE:
        case PRS1IPAPAverageEvent::TYPE:
            AddEvent(channel, t, e->m_value, e->m_gain);
            m_currentPressure = e->m_value;
            break;
        case PRS1EPAPSetEvent::TYPE:
            AddEvent(channel, t, e->m_value, e->m_gain);
            if (m_calcPSfromSet) {
                PS = *(PRS1ImportChannelMap[PRS1IPAPSetEvent::TYPE].at(1));
                AddEvent(PS, t, m_currentPressure - e->m_value, e->m_gain);  // Pressure Support
            }
            break;
        case PRS1EPAPAverageEvent::TYPE:
            PS = *channels.at(1);
            AddEvent(channel, t, e->m_value, e->m_gain);
            AddEvent(PS, t, m_currentPressure - e->m_value, e->m_gain);  // Pressure Support
            break;

        case PRS1TimedBreathEvent::TYPE:
            // The duration appears to correspond to the length of the timed breath in seconds when multiplied by 0.1 (100ms)!
            // TODO: consider changing parsers to use milliseconds for time, since it turns out there's at least one way
            // they can express durations less than 1 second.
            // TODO: consider allowing OSCAR to record millisecond durations so that the display will say "2.1" instead of "21" or "2".
            duration = e->m_duration * 100L;  // for now do this here rather than in parser, since parser events don't use milliseconds
            AddEvent(*channels.at(0), t - duration, e->m_duration * 0.1F, 0.1F);  // TODO: a gain of 0.1 should render this unnecessary, but gain doesn't seem to work currently
            break;

        case PRS1ObstructiveApneaEvent::TYPE:
        case PRS1ClearAirwayEvent::TYPE:
        case PRS1HypopneaEvent::TYPE:
        case PRS1FlowLimitationEvent::TYPE:
            AddEvent(channel, t, e->m_duration, e->m_gain);
            break;

        case PRS1PeriodicBreathingEvent::TYPE:
        case PRS1LargeLeakEvent::TYPE:
        case PRS1VariableBreathingEvent::TYPE:
            // TODO: The graphs silently treat the timestamp of a span as an end time rather than start (see gFlagsLine::paint).
            // Decide whether to preserve that behavior or change it universally and update either this code or comment.
            duration = e->m_duration * 1000L;
            AddEvent(channel, t + duration, e->m_duration, e->m_gain);
            break;

        case PRS1SnoreEvent::TYPE:  // snore count that shows up in flags but not waveform
            // TODO: The numeric snore graph is the right way to present this information,
            // but it needs to be shifted left 2 minutes, since it's not a starting value
            // but a past statistic.
            AddEvent(channel, t, e->m_value, e->m_gain);  // Snore count, continuous data
            if (e->m_value > 0) {
                // TODO: currently these get drawn on our waveforms, but they probably shouldn't,
                // since they don't have a precise timestamp. They should continue to be drawn
                // on the flags overview. See the comment in ImportEventChunk regarding flags
                // for numeric channels.
                //
                // We need to pass the count along so that the VS2 index will tabulate correctly.
                VS2 = *channels.at(1);
                AddEvent(VS2, t, e->m_value, 1);
            }
            break;
        case PRS1VibratorySnoreEvent::TYPE:  // real VS marker on waveform
            // TODO: These don't need to be drawn separately on the flag overview, since
            // they're presumably included in the overall snore count statistic. They should
            // continue to be drawn on the waveform, due to their precise timestamp.
            AddEvent(channel, t, e->m_value, e->m_gain);
            break;

        default:
            if (channels.count() == 1) {
                // For most events, simply pass the value through to the mapped channel.
                AddEvent(channel, t, e->m_value, e->m_gain);
            } else if (channels.count() > 1) {
                // Anything mapped to more than one channel must have a case statement above.
                qWarning() << "Missing import handler for PRS1 event type" << (int) e->m_type;
                break;
            } else {
                // Not imported, no channels mapped to this event
                // These will show up in chunk YAML and any user alerts will be driven by the parser.
            }
            break;
    }
}


CPAPMode PRS1Import::importMode(int prs1mode)
{
    CPAPMode mode = MODE_UNKNOWN;
    
    switch (prs1mode) {
        case PRS1_MODE_CPAPCHECK:   mode = MODE_CPAP; break;
        case PRS1_MODE_CPAP:        mode = MODE_CPAP; break;
        case PRS1_MODE_AUTOCPAP:    mode = MODE_APAP; break;
        case PRS1_MODE_AUTOTRIAL:   mode = MODE_APAP; break;
        case PRS1_MODE_BILEVEL:     mode = MODE_BILEVEL_FIXED; break;
        case PRS1_MODE_AUTOBILEVEL: mode = MODE_BILEVEL_AUTO_VARIABLE_PS; break;
        case PRS1_MODE_ASV:         mode = MODE_ASV_VARIABLE_EPAP; break;
        case PRS1_MODE_S:           mode = MODE_BILEVEL_FIXED; break;
        case PRS1_MODE_ST:          mode = MODE_BILEVEL_FIXED; break;
        case PRS1_MODE_PC:          mode = MODE_BILEVEL_FIXED; break;
        case PRS1_MODE_ST_AVAPS:    mode = MODE_AVAPS; break;
        case PRS1_MODE_PC_AVAPS:    mode = MODE_AVAPS; break;
        default:
            UNEXPECTED_VALUE(prs1mode, "known PRS1 mode");
            break;
    }

    return mode;
}


bool PRS1Import::ImportCompliance()
{
    bool ok;
    ok = compliance->ParseCompliance();
    qint64 start = qint64(compliance->timestamp) * 1000L;
    
    for (int i=0; i < compliance->m_parsedData.count(); i++) {
        PRS1ParsedEvent* e = compliance->m_parsedData.at(i);
        if (e->m_type == PRS1ParsedSliceEvent::TYPE) {
            AddSlice(start, e);
            continue;
        } else if (e->m_type != PRS1ParsedSettingEvent::TYPE) {
            qWarning() << "Compliance had non-setting event:" << (int) e->m_type;
            continue;
        }
        PRS1ParsedSettingEvent* s = (PRS1ParsedSettingEvent*) e;
        switch (s->m_setting) {
            case PRS1_SETTING_CPAP_MODE:
                session->settings[PRS1_Mode] = (PRS1Mode) e->m_value;
                session->settings[CPAP_Mode] = importMode(e->m_value);
                break;
            case PRS1_SETTING_PRESSURE:
                session->settings[CPAP_Pressure] = e->value();
                break;
            case PRS1_SETTING_PRESSURE_MIN:
                session->settings[CPAP_PressureMin] = e->value();
                break;
            case PRS1_SETTING_PRESSURE_MAX:
                session->settings[CPAP_PressureMax] = e->value();
                break;
            case PRS1_SETTING_FLEX_MODE:
                session->settings[PRS1_FlexMode] = e->m_value;
                break;
            case PRS1_SETTING_FLEX_LEVEL:
                session->settings[PRS1_FlexLevel] = e->m_value;
                break;
            case PRS1_SETTING_FLEX_LOCK:
                session->settings[PRS1_FlexLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_RAMP_TIME:
                session->settings[CPAP_RampTime] = e->m_value;
                break;
            case PRS1_SETTING_RAMP_PRESSURE:
                session->settings[CPAP_RampPressure] = e->value();
                break;
            case PRS1_SETTING_RAMP_TYPE:
                session->settings[PRS1_RampType] = e->m_value;
                break;
            case PRS1_SETTING_HUMID_STATUS:
                session->settings[PRS1_HumidStatus] = (bool) e->m_value;
                break;
            case PRS1_SETTING_HUMID_MODE:
                session->settings[PRS1_HumidMode] = e->m_value;
                break;
            case PRS1_SETTING_HEATED_TUBE_TEMP:
                session->settings[PRS1_TubeTemp] = e->m_value;
                break;
            case PRS1_SETTING_HUMID_LEVEL:
                session->settings[PRS1_HumidLevel] = e->m_value;
                break;
            case PRS1_SETTING_MASK_RESIST_LOCK:
                session->settings[PRS1_MaskResistLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_MASK_RESIST_SETTING:
                session->settings[PRS1_MaskResistSet] = e->m_value;
                break;
            case PRS1_SETTING_HOSE_DIAMETER:
                session->settings[PRS1_HoseDiam] = e->m_value;
                break;
            case PRS1_SETTING_TUBING_LOCK:
                session->settings[PRS1_TubeLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_AUTO_ON:
                session->settings[PRS1_AutoOn] = (bool) e->m_value;
                break;
            case PRS1_SETTING_AUTO_OFF:
                session->settings[PRS1_AutoOff] = (bool) e->m_value;
                break;
            case PRS1_SETTING_MASK_ALERT:
                session->settings[PRS1_MaskAlert] = (bool) e->m_value;
                break;
            case PRS1_SETTING_SHOW_AHI:
                session->settings[PRS1_ShowAHI] = (bool) e->m_value;
                break;
            default:
                qWarning() << "Unknown PRS1 setting type" << (int) s->m_setting;
                break;
        }
    }

    if (!ok) {
        return false;
    }
    if (compliance->duration == 0) {
        // This does occasionally happen and merely indicates a brief session with no useful data.
        // This requires the use of really_set_last below, which otherwise rejects 0 length.
        qDebug() << compliance->sessionid << "compliance duration == 0";
    }
    session->setSummaryOnly(true);
    session->set_first(start);
    session->really_set_last(qint64(compliance->timestamp + compliance->duration) * 1000L);

    return true;
}


void PRS1Import::AddSlice(qint64 start, PRS1ParsedEvent* e)
{
    // Cache all slices and incrementally calculate their durations.
    PRS1ParsedSliceEvent* s = (PRS1ParsedSliceEvent*) e;
    qint64 tt = start + qint64(s->m_start) * 1000L;
    if (!m_slices.isEmpty()) {
        SessionSlice & prevSlice = m_slices.last();
        prevSlice.end = tt;
    }
    m_slices.append(SessionSlice(tt, tt, (SliceStatus) s->m_value));
}


bool PRS1Import::ImportSummary()
{
    if (!summary) {
        qWarning() << "ImportSummary() called with no summary?";
        return false;
    }

    qint64 start = qint64(summary->timestamp) * 1000L;
    session->set_first(start);

    // TODO: The below max pressures aren't right for the 30 cmH2O models.
    session->setPhysMax(CPAP_LeakTotal, 120);
    session->setPhysMin(CPAP_LeakTotal, 0);
    session->setPhysMax(CPAP_Pressure, 25);
    session->setPhysMin(CPAP_Pressure, 4);
    session->setPhysMax(CPAP_IPAP, 25);
    session->setPhysMin(CPAP_IPAP, 4);
    session->setPhysMax(CPAP_EPAP, 25);
    session->setPhysMin(CPAP_EPAP, 4);
    session->setPhysMax(CPAP_PS, 25);
    session->setPhysMin(CPAP_PS, 0);
    
    bool ok;
    ok = summary->ParseSummary();
    
    PRS1Mode nativemode = PRS1_MODE_UNKNOWN;
    CPAPMode cpapmode = MODE_UNKNOWN;
    bool humidifierConnected = false;
    for (int i=0; i < summary->m_parsedData.count(); i++) {
        PRS1ParsedEvent* e = summary->m_parsedData.at(i);
        if (e->m_type == PRS1ParsedSliceEvent::TYPE) {
            AddSlice(start, e);
            continue;
        } else if (e->m_type != PRS1ParsedSettingEvent::TYPE) {
            qWarning() << "Summary had non-setting event:" << (int) e->m_type;
            continue;
        }
        PRS1ParsedSettingEvent* s = (PRS1ParsedSettingEvent*) e;
        switch (s->m_setting) {
            case PRS1_SETTING_CPAP_MODE:
                nativemode = (PRS1Mode) e->m_value;
                cpapmode = importMode(e->m_value);
                break;
            case PRS1_SETTING_PRESSURE:
                session->settings[CPAP_Pressure] = e->value();
                break;
            case PRS1_SETTING_PRESSURE_MIN:
                session->settings[CPAP_PressureMin] = e->value();
                break;
            case PRS1_SETTING_PRESSURE_MAX:
                session->settings[CPAP_PressureMax] = e->value();
                break;
            case PRS1_SETTING_EPAP:
                session->settings[CPAP_EPAP] = e->value();
                break;
            case PRS1_SETTING_IPAP:
                session->settings[CPAP_IPAP] = e->value();
                break;
            case PRS1_SETTING_PS:
                session->settings[CPAP_PS] = e->value();
                break;
            case PRS1_SETTING_EPAP_MIN:
                session->settings[CPAP_EPAPLo] = e->value();
                break;
            case PRS1_SETTING_EPAP_MAX:
                session->settings[CPAP_EPAPHi] = e->value();
                break;
            case PRS1_SETTING_IPAP_MIN:
                session->settings[CPAP_IPAPLo] = e->value();
                break;
            case PRS1_SETTING_IPAP_MAX:
                session->settings[CPAP_IPAPHi] = e->value();
                break;
            case PRS1_SETTING_PS_MIN:
                session->settings[CPAP_PSMin] = e->value();
                break;
            case PRS1_SETTING_PS_MAX:
                session->settings[CPAP_PSMax] = e->value();
                break;
            case PRS1_SETTING_FLEX_MODE:
                session->settings[PRS1_FlexMode] = e->m_value;
                break;
            case PRS1_SETTING_FLEX_LEVEL:
                session->settings[PRS1_FlexLevel] = e->m_value;
                break;
            case PRS1_SETTING_FLEX_LOCK:
                session->settings[PRS1_FlexLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_RAMP_TIME:
                session->settings[CPAP_RampTime] = e->m_value;
                break;
            case PRS1_SETTING_RAMP_PRESSURE:
                session->settings[CPAP_RampPressure] = e->value();
                break;
            case PRS1_SETTING_RAMP_TYPE:
                session->settings[PRS1_RampType] = e->m_value;
                break;
            case PRS1_SETTING_HUMID_STATUS:
                humidifierConnected = (bool) e->m_value;
                session->settings[PRS1_HumidStatus] = humidifierConnected;
                break;
            case PRS1_SETTING_HUMID_MODE:
                session->settings[PRS1_HumidMode] = e->m_value;
                break;
            case PRS1_SETTING_HEATED_TUBE_TEMP:
                session->settings[PRS1_TubeTemp] = e->m_value;
                break;
            case PRS1_SETTING_HUMID_LEVEL:
                session->settings[PRS1_HumidLevel] = e->m_value;
                break;
            case PRS1_SETTING_HUMID_TARGET_TIME:
                // Only import this setting if there's a humidifier connected.
                // (This setting appears in the data even when it's disconnected.)
                // TODO: Consider moving this logic into the parser for target time.
                if (humidifierConnected) {
                    if (e->m_value > 1) {
                        // use scaled numeric value
                        session->settings[PRS1_HumidTargetTime] = e->value();
                    } else {
                        // use unscaled 0 or 1 for Off or Auto respectively
                        session->settings[PRS1_HumidTargetTime] = e->m_value;
                    }
                }
                break;
            case PRS1_SETTING_MASK_RESIST_LOCK:
                session->settings[PRS1_MaskResistLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_MASK_RESIST_SETTING:
                session->settings[PRS1_MaskResistSet] = e->m_value;
                break;
            case PRS1_SETTING_HOSE_DIAMETER:
                session->settings[PRS1_HoseDiam] = e->m_value;
                break;
            case PRS1_SETTING_TUBING_LOCK:
                session->settings[PRS1_TubeLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_AUTO_ON:
                session->settings[PRS1_AutoOn] = (bool) e->m_value;
                break;
            case PRS1_SETTING_AUTO_OFF:
                session->settings[PRS1_AutoOff] = (bool) e->m_value;
                break;
            case PRS1_SETTING_MASK_ALERT:
                session->settings[PRS1_MaskAlert] = (bool) e->m_value;
                break;
            case PRS1_SETTING_SHOW_AHI:
                session->settings[PRS1_ShowAHI] = (bool) e->m_value;
                break;
            case PRS1_SETTING_BACKUP_BREATH_MODE:
                session->settings[PRS1_BackupBreathMode] = e->m_value;
                break;
            case PRS1_SETTING_BACKUP_BREATH_RATE:
                session->settings[PRS1_BackupBreathRate] = e->m_value;
                break;
            case PRS1_SETTING_BACKUP_TIMED_INSPIRATION:
                session->settings[PRS1_BackupBreathTi] = e->value();
                break;
            case PRS1_SETTING_TIDAL_VOLUME:
                session->settings[CPAP_TidalVolume] = e->m_value;
                break;
            case PRS1_SETTING_AUTO_TRIAL:  // new to F0V6
                session->settings[PRS1_AutoTrial] = e->m_value;
                nativemode = PRS1_MODE_AUTOTRIAL;  // Note: F0V6 reports show the underlying CPAP mode rather than Auto-Trial.
                cpapmode = importMode(nativemode);
                break;
            case PRS1_SETTING_EZ_START:
                session->settings[PRS1_EZStart] = (bool) e->m_value;
                break;
            case PRS1_SETTING_RISE_TIME:
                session->settings[PRS1_RiseTime] = e->m_value;
                break;
            case PRS1_SETTING_RISE_TIME_LOCK:
                session->settings[PRS1_RiseTimeLock] = (bool) e->m_value;
                break;
            case PRS1_SETTING_APNEA_ALARM:
            case PRS1_SETTING_DISCONNECT_ALARM:
            case PRS1_SETTING_LOW_MV_ALARM:
            case PRS1_SETTING_LOW_TV_ALARM:
                // TODO: define and add new channels for alarms once we have more samples and can reliably parse them.
                break;
            default:
                qWarning() << "Unknown PRS1 setting type" << (int) s->m_setting;
                break;
        }
    }

    if (!ok) {
        return false;
    }
    
    if (summary->m_parsedData.count() > 0) {
        if (nativemode == PRS1_MODE_UNKNOWN) UNEXPECTED_VALUE(nativemode, "known mode");
        if (cpapmode == MODE_UNKNOWN) UNEXPECTED_VALUE(cpapmode, "known mode");
        session->settings[PRS1_Mode] = nativemode;
        session->settings[CPAP_Mode] = cpapmode;
    }

    if (summary->duration == 0) {
        // This does occasionally happen and merely indicates a brief session with no useful data.
        // This requires the use of really_set_last below, which otherwise rejects 0 length.
        //qDebug() << summary->sessionid << "session duration == 0";
    }
    session->really_set_last(qint64(summary->timestamp + summary->duration) * 1000L);
    
    return true;
}


bool PRS1Import::ImportEvents()
{
    bool ok = true;
    
    for (auto & event : m_event_chunks.values()) {
        bool chunk_ok = this->ImportEventChunk(event);
        if (!chunk_ok && m_event_chunks.count() > 1) {
            // Specify which chunk had problems if there's more than one. ParseSession will warn about the overall result.
            qWarning() << event->sessionid << QString("Error parsing events in %1 @ %2, continuing")
                .arg(relativePath(event->m_path))
                .arg(event->m_filepos);
        }
        ok &= chunk_ok;
    }

    if (ok) {
        // Sanity check: warn if channels' eventlists don't line up with the final mask-on slices.
        // First make a list of the mask-on slices that will be imported (nonzero duration)
        QVector<SessionSlice> maskOn;
        for (auto & slice : m_slices) {
            if (slice.status == MaskOn) {
                if (slice.end > slice.start) {
                    maskOn.append(slice);
                } else {
                    qWarning() << this->sessionid << "Dropping empty mask-on slice:" << ts(slice.start);
                }
            }
        }
        // Then go through each required channel and make sure each eventlist is within
        // the bounds of the corresponding slice, warn if not.
        if (maskOn.count() > 0 && m_event_chunks.count() > 0) {
            QVector<SessionSlice> maskOnWithEvents = maskOn;
            if (m_event_chunks.first()->family == 3 && m_event_chunks.first()->familyVersion <= 3) {
                // F3V0 and F3V3 sometimes omit (empty) event chunks if the mask-on slice is shorter than 2 minutes.
                // Specifically, 1061401 and 1061T always do, but 1160P usually doesn't. Sometimes 1160P will omit
                // just the first event chunk if the first mask-on slice is shorter than 2 minutes.
                int empty = maskOn.count() - m_event_chunks.count();
                if (empty > 0) {
                    // If there are fewer event chunks than mask-on slices, filter the list to have just the
                    // mask-on slices that we expect to have events.
                    int skipped = 0;
                    maskOnWithEvents.clear();
                    for (auto & slice : maskOn) {
                        if (skipped < empty && slice.end - slice.start < 120 * 1000L) {
                            skipped++;
                            continue;
                        }
                        maskOnWithEvents.append(slice);
                    }
                }
            }
            if (maskOnWithEvents.count() < m_event_chunks.count()) {
                qWarning() << sessionid << "has more event chunks than mask-on slices!";
            }
            const QVector<PRS1ParsedEventType> & supported = GetSupportedEvents(m_event_chunks.first());
            for (auto & e : supported) {
                if (!PRS1OnDemandChannels.contains(e) && !PRS1NonSliceChannels.contains(e)) {
                    for (auto & pChannelID : PRS1ImportChannelMap[e]) {
                        auto & eventlists = session->eventlist[*pChannelID];
                        if (eventlists.count() != maskOnWithEvents.count()) {
                            qWarning() << sessionid << "has" << maskOnWithEvents.count() << "mask-on slices, channel"
                                << *pChannelID << "has" << eventlists.count() << "eventlists";
                            continue;
                        }
                        for (int i = 0; i < eventlists.count(); i++) {
                            if (eventlists[i]->count() == 0) continue;  // no first/last timestamp
                            auto & list = eventlists[i];
                            auto & slice = maskOnWithEvents[i];
                            if (list->first() < slice.start || list->first() > slice.end ||
                                list->last() < slice.start || list->last() > slice.end) {
                                qWarning() << sessionid << "channel" << *pChannelID << "has events outside of mask-on slice" << i;
                            }
                        }
                    }
                }
            }
        }
        // The above is just sanity-checking the results of our import process, that discontinuous
        // data is fully contained within mask-on slices.
    
        session->m_cnt.clear();
        session->m_cph.clear();

        session->m_valuesummary[CPAP_Pressure].clear();
        session->m_valuesummary.erase(session->m_valuesummary.find(CPAP_Pressure));
    }

    return ok;
}


QList<PRS1DataChunk *> PRS1Import::CoalesceWaveformChunks(QList<PRS1DataChunk *> & allchunks)
{
    QList<PRS1DataChunk *> coalesced;
    PRS1DataChunk *chunk = nullptr, *lastchunk = nullptr;
    int num;
    
    for (int i=0; i < allchunks.size(); ++i) {
        chunk = allchunks.at(i);
        
        // Log mismatched waveform session IDs
        QFileInfo fi(chunk->m_path);
        bool numeric;
        QString session_s = fi.fileName().section(".", 0, -2);
        qint32 sid = session_s.toInt(&numeric, m_sessionid_base);
        if (!numeric || sid != chunk->sessionid) {
            qWarning() << chunk->m_path << "@" << chunk->m_filepos << "session ID mismatch:" << chunk->sessionid;
        }

        if (lastchunk != nullptr) {
            // A handful of 960P waveform files have been observed to have multiple sessions.
            //
            // This breaks the current approach of deferring waveform parsing until the (multithreaded)
            // import, since each session is in a separate import task and could be in a separate
            // thread, or already imported by the time it is discovered that this file contains
            // more than one session.
            //
            // For now, we just dump the chunks that don't belong to the session currently
            // being imported in this thread, since this happens so rarely.
            //
            // TODO: Rework the import process to handle waveform data after compliance/summary/
            // events (since we're no longer inferring session information from it) and add it to the
            // newly imported sessions.
            if (lastchunk->sessionid != chunk->sessionid) {
                qWarning() << chunk->m_path << "@" << chunk->m_filepos
                    << "session ID" << lastchunk->sessionid << "->" << chunk->sessionid
                    << ", skipping" << allchunks.size() - i << "remaining chunks";
                // Free any remaining chunks
                for (int j=i; j < allchunks.size(); ++j) {
                    chunk = allchunks.at(j);
                    delete chunk;
                }
                break;
            }
            
            // Check whether the data format is the same between the two chunks
            bool same_format = (lastchunk->waveformInfo.size() == chunk->waveformInfo.size());
            if (same_format) {
                num = chunk->waveformInfo.size();
                for (int n=0; n < num; n++) {
                    const PRS1Waveform &a = lastchunk->waveformInfo.at(n);
                    const PRS1Waveform &b = chunk->waveformInfo.at(n);
                    if (a.interleave != b.interleave) {
                        // We've never seen this before
                        qWarning() << chunk->m_path << "format change?" << a.interleave << b.interleave;
                        same_format = false;
                        break;
                    }
                }
            } else {
                // We've never seen this before
                qWarning() << chunk->m_path << "channels change?" << lastchunk->waveformInfo.size() << chunk->waveformInfo.size();
            }
            
            qint64 diff = (chunk->timestamp - lastchunk->timestamp) - lastchunk->duration;
            if (same_format && diff == 0) {
                // Same format and in sync, so append waveform data to previous chunk
                lastchunk->m_data.append(chunk->m_data);
                lastchunk->duration += chunk->duration;
                delete chunk;
                continue;
            }
            // else start a new chunk to resync
        }
        
        // Report any formats we haven't seen before
        num = chunk->waveformInfo.size();
        if (num > 2) {
            qDebug() << chunk->m_path << num << "channels";
        }
        for (int n=0; n < num; n++) {
            int interleave = chunk->waveformInfo.at(n).interleave;
            switch (chunk->ext) {
                case 5:  // flow data, 5 samples per second
                    if (interleave != 5) {
                        qDebug() << chunk->m_path << "interleave?" << interleave;
                    }
                    break;
                case 6:  // oximetry, 1 sample per second
                    if (interleave != 1) {
                        qDebug() << chunk->m_path << "interleave?" << interleave;
                    }
                    break;
                default:
                    qWarning() << chunk->m_path << "unknown waveform?" << chunk->ext;
                    break;
            }
        }
        
        coalesced.append(chunk);
        lastchunk = chunk;
    }
    
    // In theory there could be broken sessions that have waveform data but no summary or events.
    // Those waveforms won't be skipped by the scanner, so we have to check for them here.
    //
    // This won't be perfect, since any coalesced chunks starting after midnight of the threshhold
    // date will also be imported, but those should be relatively few, and tolerable imprecision.
    QList<PRS1DataChunk *> coalescedAndFiltered;
    qint64 ignoreBefore = loader->context()->IgnoreSessionsOlderThan().toMSecsSinceEpoch()/1000;
    bool ignoreOldSessions = loader->context()->ShouldIgnoreOldSessions();

    for (auto & chunk : coalesced) {
        if (ignoreOldSessions && chunk->timestamp < ignoreBefore) {
            qWarning().noquote() << relativePath(chunk->m_path) << "skipping session" << chunk->sessionid << ":"
                << QDateTime::fromMSecsSinceEpoch(chunk->timestamp*1000).toString() << "older than"
                << QDateTime::fromMSecsSinceEpoch(ignoreBefore*1000).toString();
            delete chunk;
            continue;
        }
        coalescedAndFiltered.append(chunk);
    }

    return coalescedAndFiltered;
}


void PRS1Import::ImportOximetry()
{
    int size = oximetry.size();

    for (int i=0; i < size; ++i) {
        PRS1DataChunk * oxi = oximetry.at(i);
        int num = oxi->waveformInfo.size();
        CHECK_VALUE(num, 2);

        int size = oxi->m_data.size();
        if (size == 0) {
            qDebug() << oxi->sessionid << oxi->timestamp << "empty?";
            continue;
        }
        quint64 ti = quint64(oxi->timestamp) * 1000L;
        qint64 dur = qint64(oxi->duration) * 1000L;

        if (num > 1) {
            CHECK_VALUE(oxi->waveformInfo.at(0).interleave, 1);
            CHECK_VALUE(oxi->waveformInfo.at(1).interleave, 1);
            
            // Process interleaved samples
            QVector<QByteArray> data;
            data.resize(num);

            int pos = 0;
            do {
                for (int n=0; n < num; n++) {
                    int interleave = oxi->waveformInfo.at(n).interleave;
                    data[n].append(oxi->m_data.mid(pos, interleave));
                    pos += interleave;
                }
            } while (pos < size);
            CHECK_VALUE(data[0].size(), data[1].size());

            ImportOximetryChannel(OXI_Pulse, data[0], ti, dur);

            ImportOximetryChannel(OXI_SPO2, data[1], ti, dur);
        }
    }
}


void PRS1Import::ImportOximetryChannel(ChannelID channel, QByteArray & data, quint64 ti, qint64 dur)
{
    if (data.size() == 0)
        return;

    unsigned char* raw = (unsigned char*) data.data();
    qint64 step = dur / data.size();
    CHECK_VALUE(dur % data.size(), 0);
    
    bool pending_samples = false;
    quint64 start_ti;
    int start_i;
    
    // Split eventlist on invalid values (254-255)
    for (int i=0; i < data.size(); i++) {
        unsigned char value = raw[i];
        bool valid = (value < 254);

        if (valid) {
            if (pending_samples == false) {
                pending_samples = true;
                start_i  = i;
                start_ti = ti;
            }
            
            if (channel == OXI_Pulse) {
                // Values up through 253 are confirmed to be reported as valid on official reports.
            } else {
                if (value > 100) UNEXPECTED_VALUE(value, "<= 100%");
            }
        } else {
            if (pending_samples) {
                // Create the pending event list
                EventList* el = session->AddEventList(channel, EVL_Waveform, 1.0, 0.0, 0.0, 0.0, step);
                el->AddWaveform(start_ti, &raw[start_i], i - start_i, ti - start_ti);
                pending_samples = false;
            }
        }
        ti += step;
    }

    if (pending_samples) {
        // Create the pending event list
        EventList* el = session->AddEventList(channel, EVL_Waveform, 1.0, 0.0, 0.0, 0.0, step);
        el->AddWaveform(start_ti, &raw[start_i], data.size() - start_i, ti - start_ti);
        pending_samples = false;
    }
}


void PRS1Import::ImportWaveforms()
{
    int size = waveforms.size();
    quint64 s1, s2;

    int discontinuities = 0;
    qint64 lastti=0;

    for (int i=0; i < size; ++i) {
        PRS1DataChunk * waveform = waveforms.at(i);
        int num = waveform->waveformInfo.size();

        int size = waveform->m_data.size();
        if (size == 0) {
            qDebug() << waveform->sessionid << waveform->timestamp << "empty?";
            continue;
        }
        quint64 ti = quint64(waveform->timestamp) * 1000L;
        quint64 dur = qint64(waveform->duration) * 1000L;

        qint64 diff = ti - lastti;
        if ((lastti != 0) && (diff == 1000 || diff == -1000)) {
            // TODO: Handle discontinuities properly.
            // Option 1: preserve the discontinuity and make it apparent:
            // - In the case of a 1-sec overlap, truncate the previous waveform by 1s (+1 sample).
            // - Then start a new eventlist for the new section.
            // > The down side of this approach is gaps in the data.
            // Option 2: slide the waveform data a fraction of a second to avoid the discontinuity
            // - In the case of a single discontinuity, simply adjust the timestamps of each section by 0.5s so they meet.
            // - In the case of multiple discontinuities, fitting them is more complicated
            // > The down side of this approach is that events won't line up exactly the same as official reports.
            //
            // Evidently the machines' internal clock drifts slightly, and in some sessions that
            // means two adjacent (5-minute) waveform chunks have have a +/- 1 second difference in
            // their notion of the correct time, since the machines only record time at 1-second
            // resolution. Presumably the real drift is fractional, but there's no way to tell from
            // the data.
            //
            // Encore apparently drops the second chunk entirely if it overlaps with the first
            // (even by 1 second), and inserts a 1-second gap in the data if it's 1 second later than
            // the first ended.
            //
            // At worst in the former case it seems preferable to drop the overlap and then one
            // additional second to mark the discontinuity. But depending how often these drifts
            // occur, it may be possible to adjust all the data so that it's continuous. "Overlapping"
            // data is not identical, so it seems like these discontinuities are simply an artifact
            // of timestamping at 1-second intervals right around the 1-second boundary.

            //qDebug() << waveform->sessionid << "waveform discontinuity:" << (diff / 1000L) << "s @" << ts(waveform->timestamp * 1000L);
            discontinuities++;
        }

        if (num > 1) {
            float pressure_gain = 0.1F;  // standard pressure gain
            if ((waveform->family == 5 && (waveform->familyVersion == 2 || waveform->familyVersion == 3)) ||
                (waveform->family == 3 && waveform->familyVersion == 6)){
                // F5V2, F5V3, and F3V6 use a gain of 0.125 rather than 0.1 to allow for a maximum value of 30 cmH2O
                pressure_gain = 0.125F;  // TODO: this should be parameterized somewhere better, once we have a clear idea of which machines use this
            }
            
            // Process interleaved samples
            QVector<QByteArray> data;
            data.resize(num);

            int pos = 0;
            do {
                for (int n=0; n < num; n++) {
                    int interleave = waveform->waveformInfo.at(n).interleave;
                    data[n].append(waveform->m_data.mid(pos, interleave));
                    pos += interleave;
                }
            } while (pos < size);

            s1 = data[0].size();
            s2 = data[1].size();

            if (s1 > 0) {
                EventList * flow = session->AddEventList(CPAP_FlowRate, EVL_Waveform, 1.0f, 0.0f, 0.0f, 0.0f, double(dur) / double(s1));
                flow->AddWaveform(ti, (char *)data[0].data(), data[0].size(), dur);
            }

            if (s2 > 0) {
                // NOTE: The 900X (F5V3) clamps the values at 127 (15.875 cmH2O) for some reason.
                //
                // Previous autoSV machines (950P-961P, F5V0-F5V2) didn't, nor do 1030X (F3V6).
                // 1130X (also F3V6) is unknown, but likely follows the 1030X. Older ventilators
                // (F3V3) are also unknown.
                EventList * pres = session->AddEventList(CPAP_MaskPressureHi, EVL_Waveform, pressure_gain, 0.0f, 0.0f, 0.0f, double(dur) / double(s2));
                pres->AddWaveform(ti, (unsigned char *)data[1].data(), data[1].size(), dur);
            }

        } else {
            // Non interleaved, so can process it much faster
            EventList * flow = session->AddEventList(CPAP_FlowRate, EVL_Waveform, 1.0f, 0.0f, 0.0f, 0.0f, double(dur) / double(waveform->m_data.size()));
            flow->AddWaveform(ti, (char *)waveform->m_data.data(), waveform->m_data.size(), dur);
        }
        lastti = dur+ti;
    }
    
    if (discontinuities > 1) {
        qWarning() << session->session() << "multiple discontinuities!" << discontinuities;
    }
}

void PRS1Import::run()
{
    if (ParseSession()) {
        loader->context()->AddSession(session);
    }
}


bool PRS1Import::ParseSession(void)
{
    bool ok = false;
    bool save = false;
    session = loader->context()->CreateSession(sessionid);

    do {
        if (compliance != nullptr) {
            ok = ImportCompliance();
            if (!ok) {
                // We don't see any parse errors with our test data, so warn if there's ever an error encountered.
                qWarning() << sessionid << "Error parsing compliance, skipping session";
                break;
            }
        }
        if (summary != nullptr) {
            if (compliance != nullptr) {
                qWarning() << sessionid << "Has both compliance and summary?!";
                // Never seen this, but try the summary anyway.
            }
            ok = ImportSummary();
            if (!ok) {
                // We don't see any parse errors with our test data, so warn if there's ever an error encountered.
                qWarning() << sessionid << "Error parsing summary, skipping session";
                break;
            }
        }
        if (compliance == nullptr && summary == nullptr) {
            // With one exception, the only time we've seen missing .000 or .001 data has been with a corrupted card,
            // or occasionally with partial cards where the .002 is the first file in the Pn directory
            // and we're missing the preceding directory. Since the lack of compliance or summary means we
            // don't know the therapy settings or if the mask was ever off, we just skip this very rare case.
            qWarning() << sessionid << "No compliance or summary, skipping session";
            break;
        }
        
        // Import the slices into the session
        for (auto & slice : m_slices) {
            // Filter out 0-length slices, since they cause problems for Day::total_time().
            if (slice.end > slice.start) {
                // Filter out everything except mask on/off, since gSessionTimesChart::paint assumes those are the only options.
                if (slice.status == MaskOn) {
                    session->m_slices.append(slice);
                } else if (slice.status == MaskOff) {
                    // Mark this slice as BND
                    AddEvent(PRS1_BND, slice.end, (slice.end - slice.start) / 1000L, 1.0);
                    session->m_slices.append(slice);
                }
            }
        }
        
        // If are no mask-on slices, then there's not any meaningful event or waveform data for the session.
        // If there's no no event or waveform data, mark this session as a summary.
        if (session->m_slices.count() == 0 || (m_event_chunks.count() == 0 && m_wavefiles.isEmpty() && m_oxifiles.isEmpty())) {
            session->setSummaryOnly(true);
            save = true;
            break;  // and skip the occasional fragmentary event or waveform data
        }

        // TODO: There should be a way to distinguish between no-data-to-import vs. parsing errors
        // (once we figure out what's benign and what isn't).
        if (m_event_chunks.count() > 0) {
            ok = ImportEvents();
            if (!ok) {
                qWarning() << sessionid << "Error parsing events, proceeding anyway?";
            }
        }

        if (!m_wavefiles.isEmpty()) {
            // Parse .005 Waveform files
            waveforms = ReadWaveformData(m_wavefiles, "Waveform");

            // Extract and import raw data into channels.
            ImportWaveforms();
        }

        if (!m_oxifiles.isEmpty()) {
            // Parse .006 Waveform files
            oximetry = ReadWaveformData(m_oxifiles, "Oximetry");

            // Extract and import raw data into channels.
            ImportOximetry();
        }

        save = true;
    } while (false);
    
    return save;
}


QList<PRS1DataChunk *> PRS1Import::ReadWaveformData(QList<QString> & files, const char* label)
{
    QMap<qint64,PRS1DataChunk *> waveform_chunks;
    QList<PRS1DataChunk *> result;

    if (files.count() > 1) {
        qDebug() << session->session() << label << "data split across multiple files";
    }
    
    for (auto & f : files) {
        // Parse a single .005 or .006 waveform file
        QList<PRS1DataChunk *> file_chunks = loader->ParseFile(f);
        for (auto & chunk : file_chunks) {
            PRS1DataChunk* previous = waveform_chunks[chunk->timestamp];
            if (previous != nullptr) {
                // Skip any chunks with identical timestamps. Never yet seen, so warn.
                qWarning() << chunkComparison(chunk, previous);
                delete chunk;
                continue;
            }
            waveform_chunks[chunk->timestamp] = chunk;
        }
    }
    
    // Get the list of pointers sorted by timestamp.
    result = waveform_chunks.values();

    // Coalesce contiguous waveform chunks into larger chunks.
    result = CoalesceWaveformChunks(result);

    return result;
}


QList<PRS1DataChunk *> PRS1Loader::ParseFile(const QString & path)
{
    QList<PRS1DataChunk *> CHUNKS;

    if (path.isEmpty()) {
        // ParseSession passes empty filepaths for waveforms if none exist.
        //qWarning() << path << "ParseFile given empty path";
        return CHUNKS;
    }

    QFile f(path);

    if (!f.exists()) {
        qWarning() << path << "missing";
        return CHUNKS;
    }

    if (!f.open(QIODevice::ReadOnly)) {
        qWarning() << path << "can't open";
        return CHUNKS;
    }
    
    RawDataFile* src;
    if (QFileInfo(f).suffix().toUpper().startsWith("B")) {  // .B01, .B02, etc.
        // If it's a DS2 file, insert the DS2 wrapper to decode the chunk stream.
        PRDS2File* ds2 = new PRDS2File(f);
        if (!ds2->isValid()) {
            qWarning() << path << "unable to decrypt";
            delete ds2;
            return CHUNKS;
        }
        src = ds2;
    } else {
        // Otherwise just use the file as input.
        src = new RawDataFile(f);
    }

    PRS1DataChunk *chunk = nullptr, *lastchunk = nullptr;

    int cnt = 0;

    do {
        chunk = PRS1DataChunk::ParseNext(*src, this);
        if (chunk == nullptr) {
            break;
        }
        chunk->SetIndex(cnt);  // for logging/debugging purposes

        if (lastchunk != nullptr) {
            if ((lastchunk->fileVersion != chunk->fileVersion)
                    || (lastchunk->ext != chunk->ext)
                    || (lastchunk->family != chunk->family)
                    || (lastchunk->familyVersion != chunk->familyVersion)
                    || (lastchunk->htype != chunk->htype)) {
                QString message = "*** unexpected change in header data";
                qWarning() << path << message;
                m_ctx->LogUnexpectedMessage(message);
                // There used to be error-recovery code here, written before we checked CRCs.
                // If we ever encounter data with a valid CRC that triggers the above warnings,
                // we can then revisit how to handle it.
            }
        }

        CHUNKS.append(chunk);
        lastchunk = chunk;
        cnt++;
    } while (!src->atEnd());

    delete src;
    return CHUNKS;
}


bool initialized = false;

using namespace schema;

Channel PRS1Channels;

void PRS1Loader::initChannels()
{
    Channel * chan = nullptr;

    channel.add(GRP_CPAP, new Channel(CPAP_PressurePulse = 0x1009, MINOR_FLAG,  MT_CPAP,   SESSION,
        "PressurePulse",
        QObject::tr("Pressure Pulse"),
        QObject::tr("A pulse of pressure 'pinged' to detect a closed airway."),
        QObject::tr("PP"),
        STR_UNIT_EventsPerHour,    DEFAULT,    QColor("dark red")));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_Mode = 0xe120, SETTING,  MT_CPAP,  SESSION,
        "PRS1Mode", QObject::tr("Mode"),
        QObject::tr("PAP Mode"),
        QObject::tr("Mode"),
        "", LOOKUP, Qt::green));
    chan->addOption(PRS1_MODE_CPAPCHECK, QObject::tr("CPAP-Check"));
    chan->addOption(PRS1_MODE_CPAP,      QObject::tr("CPAP"));
    chan->addOption(PRS1_MODE_AUTOCPAP,  QObject::tr("AutoCPAP"));
    chan->addOption(PRS1_MODE_AUTOTRIAL, QObject::tr("Auto-Trial"));
    chan->addOption(PRS1_MODE_BILEVEL,   QObject::tr("Bi-Level"));
    chan->addOption(PRS1_MODE_AUTOBILEVEL, QObject::tr("AutoBiLevel"));
    chan->addOption(PRS1_MODE_ASV,       QObject::tr("ASV"));
    chan->addOption(PRS1_MODE_S,         QObject::tr("S"));
    chan->addOption(PRS1_MODE_ST,        QObject::tr("S/T"));
    chan->addOption(PRS1_MODE_PC,        QObject::tr("PC"));
    chan->addOption(PRS1_MODE_ST_AVAPS,  QObject::tr("S/T - AVAPS"));
    chan->addOption(PRS1_MODE_PC_AVAPS,  QObject::tr("PC - AVAPS"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_FlexMode = 0xe105, SETTING,  MT_CPAP,  SESSION,
        "PRS1FlexMode", QObject::tr("Flex Mode"),
        QObject::tr("PRS1 pressure relief mode."),
        QObject::tr("Flex Mode"),
        "", LOOKUP, Qt::green));
    chan->addOption(FLEX_None, STR_TR_None);
    chan->addOption(FLEX_CFlex, QObject::tr("C-Flex"));
    chan->addOption(FLEX_CFlexPlus, QObject::tr("C-Flex+"));
    chan->addOption(FLEX_AFlex, QObject::tr("A-Flex"));
    chan->addOption(FLEX_PFlex, QObject::tr("P-Flex"));
    chan->addOption(FLEX_RiseTime, QObject::tr("Rise Time"));
    chan->addOption(FLEX_BiFlex, QObject::tr("Bi-Flex"));
    //chan->addOption(FLEX_AVAPS, QObject::tr("AVAPS"));  // Converted into AVAPS PRS1_Mode with FLEX_RiseTime
    chan->addOption(FLEX_Flex, QObject::tr("Flex"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_FlexLevel = 0xe106, SETTING, MT_CPAP,   SESSION,
        "PRS1FlexSet",
        QObject::tr("Flex Level"),
        QObject::tr("PRS1 pressure relief setting."),
        QObject::tr("Flex Level"),
        "", LOOKUP, Qt::blue));
    chan->addOption(0, STR_TR_Off);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_FlexLock = 0xe111, SETTING, MT_CPAP,   SESSION,
        "PRS1FlexLock",
        QObject::tr("Flex Lock"),
        QObject::tr("Whether Flex settings are available to you."),
        QObject::tr("Flex Lock"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_RiseTime = 0xe119, SETTING, MT_CPAP,   SESSION,
        "PRS1RiseTime",
        QObject::tr("Rise Time"),
        QObject::tr("Amount of time it takes to transition from EPAP to IPAP, the higher the number the slower the transition"),
        QObject::tr("Rise Time"),
        "", LOOKUP, Qt::blue));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_RiseTimeLock = 0xe11a, SETTING, MT_CPAP,   SESSION,
        "PRS1RiseTimeLock",
        QObject::tr("Rise Time Lock"),
        QObject::tr("Whether Rise Time settings are available to you."),
        QObject::tr("Rise Lock"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_HumidStatus = 0xe101, SETTING, MT_CPAP, SESSION,
        "PRS1HumidStat",
        QObject::tr("Humidifier Status"),
        QObject::tr("PRS1 humidifier connected?"),
        QObject::tr("Humidifier"),
        "", LOOKUP, Qt::green));
    chan->addOption(0, QObject::tr("Disconnected"));
    chan->addOption(1, QObject::tr("Connected"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_HumidMode = 0xe110, SETTING, MT_CPAP, SESSION,
        "PRS1HumidMode",
        QObject::tr("Humidification Mode"),
        QObject::tr("PRS1 Humidification Mode"),
        QObject::tr("Humid. Mode"),
        "", LOOKUP, Qt::green));
    chan->addOption(HUMID_Fixed, QObject::tr("Fixed (Classic)"));
    chan->addOption(HUMID_Adaptive, QObject::tr("Adaptive (System One)"));
    chan->addOption(HUMID_HeatedTube, QObject::tr("Heated Tube"));
    chan->addOption(HUMID_Passover, QObject::tr("Passover"));
    chan->addOption(HUMID_Error, QObject::tr("Error"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_TubeTemp = 0xe10f, SETTING, MT_CPAP,  SESSION,
        "PRS1TubeTemp",
        QObject::tr("Tube Temperature"),
        QObject::tr("PRS1 Heated Tube Temperature"),
        QObject::tr("Tube Temp."),
        "", LOOKUP, Qt::red));
    chan->addOption(0, STR_TR_Off);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_HumidLevel = 0xe102, SETTING,  MT_CPAP,  SESSION,
        "PRS1HumidLevel",
        QObject::tr("Humidifier"),  // label varies in reports, "Humidifier Setting" in 50-series, "Humidity Level" in 60-series, "Humidifier" in DreamStation
        QObject::tr("PRS1 Humidifier Setting"),
        QObject::tr("Humid. Level"),
        "", LOOKUP, Qt::blue));
    chan->addOption(0, STR_TR_Off);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_HumidTargetTime = 0xe11b, SETTING, MT_CPAP, SESSION,
        "PRS1HumidTargetTime",
        QObject::tr("Target Time"),
        QObject::tr("PRS1 Humidifier Target Time"),
        QObject::tr("Hum. Tgt Time"),
        STR_UNIT_Hours, DEFAULT, Qt::green));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, QObject::tr("Auto"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_MaskResistSet = 0xe104, SETTING, MT_CPAP,   SESSION,
        "MaskResistSet",
        QObject::tr("Mask Resistance Setting"),
        QObject::tr("Mask Resistance Setting"),
        QObject::tr("Mask Resist."),
        "", LOOKUP, Qt::green));
    chan->addOption(0, STR_TR_Off);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_HoseDiam = 0xe107, SETTING,  MT_CPAP,  SESSION,
        "PRS1HoseDiam",
        QObject::tr("Hose Diameter"),
        QObject::tr("Diameter of primary CPAP hose"),
        QObject::tr("Hose Diam."),
        "", LOOKUP, Qt::green));
    chan->addOption(22, QObject::tr("22mm"));
    chan->addOption(15, QObject::tr("15mm"));
    chan->addOption(12, QObject::tr("12mm"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_TubeLock = 0xe112, SETTING, MT_CPAP,   SESSION,
        "PRS1TubeLock",
        QObject::tr("Tubing Type Lock"),
        QObject::tr("Whether tubing type settings are available to you."),
        QObject::tr("Tube Lock"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_MaskResistLock = 0xe108, SETTING,  MT_CPAP,  SESSION,
        "MaskResistLock",
        QObject::tr("Mask Resistance Lock"),
        QObject::tr("Whether mask resistance settings are available to you."),
        QObject::tr("Mask Res. Lock"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_AutoOn = 0xe109, SETTING, MT_CPAP,   SESSION,
        "PRS1AutoOn",
        QObject::tr("Auto On"),
        QObject::tr("A few breaths automatically starts machine"),
        QObject::tr("Auto On"),
        "", LOOKUP, Qt::green));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_AutoOff = 0xe10a, SETTING, MT_CPAP,   SESSION,
        "PRS1AutoOff",
        QObject::tr("Auto Off"),
        QObject::tr("Machine automatically switches off"),
        QObject::tr("Auto Off"),
        "", LOOKUP, Qt::green));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_MaskAlert = 0xe10b, SETTING,  MT_CPAP,  SESSION,
        "PRS1MaskAlert",
        QObject::tr("Mask Alert"),
        QObject::tr("Whether or not machine allows Mask checking."),
        QObject::tr("Mask Alert"),
        "", LOOKUP, Qt::green));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_ShowAHI = 0xe10c, SETTING, MT_CPAP,   SESSION,
        "PRS1ShowAHI",
        QObject::tr("Show AHI"),
        QObject::tr("Whether or not machine shows AHI via built-in display."),
        QObject::tr("Show AHI"),
        "", LOOKUP, Qt::green));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_RampType = 0xe113, SETTING, MT_CPAP,   SESSION,
        "PRS1RampType",
        QObject::tr("Ramp Type"),
        QObject::tr("Type of ramp curve to use."),
        QObject::tr("Ramp Type"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, QObject::tr("Linear"));
    chan->addOption(1, QObject::tr("SmartRamp"));
    chan->addOption(2, QObject::tr("Ramp+"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_BackupBreathMode = 0xe114, SETTING, MT_CPAP,   SESSION,
        "PRS1BackupBreathMode",
        QObject::tr("Backup Breath Mode"),
        QObject::tr("The kind of backup breath rate in use: none (off), automatic, or fixed"),
        QObject::tr("Breath Rate"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, QObject::tr("Auto"));
    chan->addOption(2, QObject::tr("Fixed"));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_BackupBreathRate = 0xe115, SETTING, MT_CPAP,   SESSION,
        "PRS1BackupBreathRate",
        QObject::tr("Fixed Backup Breath BPM"),
        QObject::tr("Minimum breaths per minute (BPM) below which a timed breath will be initiated"),
        QObject::tr("Breath BPM"),
        STR_UNIT_BreathsPerMinute, LOOKUP, Qt::black));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_BackupBreathTi = 0xe116, SETTING, MT_CPAP,   SESSION,
        "PRS1BackupBreathTi",
        QObject::tr("Timed Inspiration"),
        QObject::tr("The time that a timed breath will provide IPAP before transitioning to EPAP"),
        QObject::tr("Timed Insp."),
        STR_UNIT_Seconds, DEFAULT, Qt::blue));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_AutoTrial = 0xe117, SETTING, MT_CPAP,   SESSION,
        "PRS1AutoTrial",
        QObject::tr("Auto-Trial Duration"),
        QObject::tr("The number of days in the Auto-CPAP trial period, after which the machine will revert to CPAP"),
        QObject::tr("Auto-Trial Dur."),
        "", LOOKUP, Qt::black));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_EZStart = 0xe118, SETTING, MT_CPAP,   SESSION,
        "PRS1EZStart",
        QObject::tr("EZ-Start"),
        QObject::tr("Whether or not EZ-Start is enabled"),
        QObject::tr("EZ-Start"),
        "", LOOKUP, Qt::black));
    chan->addOption(0, STR_TR_Off);
    chan->addOption(1, STR_TR_On);

    channel.add(GRP_CPAP, chan = new Channel(PRS1_VariableBreathing = 0x1156, SPAN, MT_CPAP,    SESSION,
        "PRS1_VariableBreathing",
        QObject::tr("Variable Breathing"),
        QObject::tr("UNCONFIRMED: Possibly variable breathing, which are periods of high deviation from the peak inspiratory flow trend"),
        "VB",
        STR_UNIT_Seconds,
        DEFAULT,    QColor("#ffe8f0")));
    chan->setEnabled(false);  // disable by default

    channel.add(GRP_CPAP, new Channel(PRS1_BND = 0x1159, SPAN,  MT_CPAP,   SESSION,
        "PRS1_BND",
        QObject::tr("Breathing Not Detected"),
        QObject::tr("A period during a session where the machine could not detect flow."),
        QObject::tr("BND"),
        STR_UNIT_Unknown,
        DEFAULT,    QColor("light purple")));
    channel.add(GRP_CPAP, new Channel(PRS1_TimedBreath = 0x1180, MINOR_FLAG, MT_CPAP,    SESSION,
        "PRS1TimedBreath",
        QObject::tr("Timed Breath"),
        QObject::tr("Machine Initiated Breath"),
        QObject::tr("TB"),
        STR_UNIT_Seconds,
        DEFAULT,    QColor("black")));

    channel.add(GRP_CPAP, chan = new Channel(PRS1_PeakFlow = 0x115a, WAVEFORM, MT_CPAP, SESSION,
        "PRS1PeakFlow",
        QObject::tr("Peak Flow"),
        QObject::tr("Peak flow during a 2-minute interval"),
        QObject::tr("Peak Flow"),
        STR_UNIT_LPM,
        DEFAULT,    QColor("red")));
    chan->setShowInOverview(true);
}

void PRS1Loader::Register()
{
    if (initialized) { return; }

    qDebug() << "Registering PRS1Loader";
    RegisterLoader(new PRS1Loader());
    initialized = true;
}