#include "fileops.h"

#include "uisocket.h"
#include "data/project.h"
#include "data/graph.h"

#include <QDebug>

#include <QFile>
#include <QCborMap>
#include <QCborArray>
#include <QCborStreamReader>
#include <QCborStreamWriter>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>

#include <QUndoStack>
#include <QFileDialog>

#define qs QStringLiteral

using Xybrid::Data::Project;
using Xybrid::Data::Pattern;
using Xybrid::Data::Sample;
using Xybrid::Data::Graph;
using Xybrid::Data::Node;

namespace FileOps = Xybrid::FileOps;

namespace {
    constexpr uint32_t packedVersion(uint8_t major = 0, uint8_t minor = 0, uint8_t revision = 0, uint8_t wip = 0) {
        return + (static_cast<uint32_t>(wip))
               + (static_cast<uint32_t>(revision)<<8)
               + (static_cast<uint32_t>(minor)<<16)
               + (static_cast<uint32_t>(major)<<24);
    }
    constexpr const uint32_t XYBRID_VERSION = packedVersion(0,0,0,1);
}

const QString FileOps::Filter::project = qs("Xybrid project (*.xyp);;All files (*)");
const QString FileOps::Filter::node = qs("Xybrid node (*.xyn);;All files (*)");

const QString FileOps::Filter::audioIn = qs("Audio files (*.mp3, *.ogg, *.flac, *.wav);;MPEG Layer 3 (*.mp3);;All files (*)");
const QString FileOps::Filter::audioOut = qs("MPEG Layer 3 (*.mp3)"); // only supported formats

QString FileOps::showOpenDialog(QWidget* parent, const QString& caption, const QString& directory, const QString& filter) {
    return QFileDialog::getOpenFileName(parent, caption, directory, filter); // just a wrapper for now
}

QString FileOps::showSaveAsDialog(QWidget* parent, const QString& caption, const QString& directory, const QString& filter, const QString& suffix) {
    QFileDialog dlg(parent, caption, directory, filter);
    dlg.setDefaultSuffix(suffix);
    dlg.setFileMode(QFileDialog::AnyFile);
    dlg.setAcceptMode(QFileDialog::AcceptSave);
    if (!dlg.exec()) return QString(); // canceled
    return dlg.selectedFiles()[0];
}

bool FileOps::saveProject(std::shared_ptr<Project> project, QString fileName) {
    if (!fileName.isEmpty()) project->fileName = fileName;
    else fileName = project->fileName;
    if (fileName.isEmpty()) return false; // fail

    QFile file(fileName);
    if (!file.open(QFile::WriteOnly)) return false;

    // header
    QCborArray root;
    root << "xybrid:project" << XYBRID_VERSION;
    { /* Main body */ } {
        QCborMap main;

        { /* Project metadata */ } {
            QCborMap meta;

            meta[qs("artist")] = project->artist;
            meta[qs("title")] = project->title;
            meta[qs("comment")] = project->comment;
            meta[qs("tempo")] = project->tempo;

            main[qs("meta")] = meta;
        }

        { /* Sequence */ } {
            QCborArray seq;
            for (auto& s : project->sequence) seq << s;
            main[qs("sequence")] = seq;
        }

        { /* Patterns */ } {
            QCborArray ptns;
            for (auto p : project->patterns) {
                QCborMap pm;
                pm[qs("name")] = p->name;
                pm[qs("fold")] = p->fold;
                pm[qs("tempo")] = p->tempo;

                {
                    QCborArray ts;
                    ts << p->time.beatsPerMeasure << p->time.rowsPerBeat << p->time.ticksPerRow;
                    pm[qs("time")] =  ts;
                }

                QCborArray chns;
                bool needsCount = true;
                for (auto& ch : p->channels) {
                    QCborMap chm;
                    chm[qs("name")] = ch.name;
                    QCborArray rows;
                    int skipped = 0;
                    for (auto& r : ch.rows) {
                        if (r.isEmpty()) {
                            skipped++; // compact empty rows into a simple number
                            continue;
                        } else if (skipped > 0) { // ...and insert said number only if there's data after
                            rows << skipped;
                            skipped = 0;
                        }
                        QCborArray row;
                        row << r.port << r.note;
                        if (r.params) for (auto p : *r.params) row << p[0] << p[1];
                        rows << row;
                    }
                    if (!ch.rows.back().isEmpty()) needsCount = false; // omit extra count if any channel has data on the last row
                    chm[qs("rows")] = rows;
                    chns << chm;
                }
                if (needsCount) chns << p->rows; // if no pattern data reaches the last row, store a manual count
                if (chns.size() != 0) pm[qs("channels")] = chns;
                ptns << pm;
            }
            main[qs("patterns")] = ptns;
        }

        { /* Samples */ } {
            QCborMap smp;
            for (auto s : project->samples) smp[QCborValue(s->uuid)] = s->toCbor();
            main[qs("samples")] = smp;
        }

        { /* Graph */ } {
            QCborMap g;
            project->rootGraph->saveData(g);
            main[qs("graph")] = g;
        }

        root << main;
    }

    // write out
    QCborStreamWriter w(&file);
    root.toCborValue().toCbor(w);
    file.close();

    if (project->socket && project->socket->undoStack) project->socket->undoStack->setClean();

    return true;
}

std::shared_ptr<Project> FileOps::loadProject(QString fileName) {
    QCborArray root;
    {
        QFile file(fileName);
        if (!file.open(QFile::ReadOnly)) return nullptr;
        QCborStreamReader read(&file);

        // if it's not an array it'll just return an empty which will fail the header check anyway
        root = QCborValue::fromCbor(read).toArray();
        file.close(); // don't want to leave this hanging any longer than necessary
    }

    // header and sanity checks
    if (root.at(0) != QString("xybrid:project")) return nullptr; // not a project
    if (auto v = root.at(1); !v.isInteger() || v.toInteger() > XYBRID_VERSION) return nullptr; // invalid version or too new
    if (!root.at(2).isMap()) return nullptr; // so close, but... nope

    // intentionally allocate project and control block separately
    std::shared_ptr<Project> project(new Project());
    project->fileName = fileName;
    QCborMap main = root.at(2).toMap();

    { /* Project metadata */ } {
        QCborMap meta = main.value("meta").toMap();
        project->artist = meta.value("artist").toString();
        project->title = meta.value("title").toString();
        project->comment = meta.value("comment").toString();
        project->tempo = meta.value("tempo").toDouble(project->tempo);
    }

    { /* Patterns */ } {
        QCborArray ptns = main.value("patterns").toArray();
        project->patterns.reserve(static_cast<size_t>(ptns.size()));
        for (auto pm_ : ptns) {
            auto pm = pm_.toMap();
            auto p = std::make_shared<Pattern>(pm.value("rows").toInteger(1));
            p->project = project.get();
            project->patterns.push_back(p);
            p->name = pm.value("name").toString();
            p->fold = static_cast<int>(pm.value("fold").toInteger(0));
            p->tempo = pm.value("tempo").toDouble(p->tempo);

            if (auto ts = pm.value("time").toArray(); !ts.empty())
                p->time = Data::TimeSignature(static_cast<int>(ts[0].toInteger()), static_cast<int>(ts[1].toInteger()), static_cast<int>(ts[2].toInteger()));

            auto chns = pm.value("channels").toArray();
            for (auto chm_ : chns) {
                if (chm_.isInteger()) { // can have a number of rows encoded as part of the channels map
                    p->rows = std::max(p->rows, static_cast<int>(chm_.toInteger()));
                    continue;
                }
                auto chm = chm_.toMap();
                auto& ch = p->channels.emplace_back();
                ch.name = chm.value("name").toString();

                auto rows = chm.value("rows").toArray();
                ch.rows.reserve(static_cast<size_t>(rows.size()));
                for (auto row_ : rows) {
                    if (row_.isInteger()) { // empty space encoded as a number of rows to skip
                        for (auto i = row_.toInteger(); i > 0; i--) ch.rows.emplace_back();
                        continue;
                    }
                    auto& r = ch.rows.emplace_back();
                    if (row_.isNull()) continue;
                    auto row = row_.toArray();
                    r.port = static_cast<int16_t>(row.at(0).toInteger(-1));
                    r.note = static_cast<int16_t>(row.at(1).toInteger(-1));
                    for (int i = 2; i < row.size(); i += 2) r.addParam(static_cast<char>(row.at(i).toInteger(' ')), static_cast<unsigned char>(row.at(i+1).toInteger()));
                }
                p->rows = std::max(p->rows, static_cast<int>(ch.rows.size()));
            }

            p->setLength(p->rows);
        }
        project->updatePatternIndices();
    }

    { /* Sequence */ } {
        QCborArray seq = main.value("sequence").toArray();
        project->sequence.reserve(static_cast<size_t>(seq.size()));
        for (auto s : seq) project->sequence.emplace_back(project.get(), QCborValue(s));
    }

    { /* Samples */ } {
        QCborMap smp = main.value("samples").toMap();
        for (auto it = smp.constBegin(), end = smp.constEnd(); it != end; ++it) {
            auto s = Sample::fromCbor(it.value(), it.key().toUuid());
            s->project = project.get();
            project->samples.insert(s->uuid, s);
            //qDebug() << "loaded sample " << s->name << "of length" << s->length() << "with" << s->numChannels() << "channels";
        }
    }

    { /* Graph */ } {
        QCborMap g = main.value("graph").toMap();
        if (!g.isEmpty()) project->rootGraph->loadData(g);
    }

    return project;
}

bool FileOps::saveNode(std::shared_ptr<Node> node, QString fileName) {
    QFile file(fileName);
    if (!file.open(QFile::WriteOnly)) return false;

    Sample::startExport();

    // we handle header and let the node handle the rest of its conversion
    QCborArray root;
    root << "xybrid:node" << XYBRID_VERSION << node->toCbor();

    // and write in any exported samples
    if (auto v = Sample::finishExport(); !v.empty()) {
        QCborMap smp;
        for (auto s : v) smp[QCborValue(s->uuid)] = s->toCbor();
        root << smp;
    }

    // write out
    QCborStreamWriter w(&file);
    root.toCborValue().toCbor(w);
    file.close();

    return true;
}

std::shared_ptr<Node> FileOps::loadNode(QString fileName, std::shared_ptr<Graph> parent) {
    QCborArray root;
    {
        QFile file(fileName);
        if (!file.open(QFile::ReadOnly)) return nullptr;
        QCborStreamReader read(&file);

        // if it's not an array it'll just return an empty which will fail the header check anyway
        root = QCborValue::fromCbor(read).toArray();
        file.close(); // don't want to leave this hanging any longer than necessary
    }

    // header and sanity checks
    if (root.at(0) != QString("xybrid:node")) return nullptr; // not a node
    if (auto v = root.at(1); !v.isInteger() || v.toInteger() > XYBRID_VERSION) return nullptr; // invalid version or too new
    if (!root.at(2).isMap()) return nullptr; // so close, but... nope

    if (root.at(3).isMap()) { // node file has samples, load in any we don't already have
        auto smp = root.at(3).toMap();
        auto project = parent->project;
        for (auto it = smp.constBegin(), end = smp.constEnd(); it != end; ++it) {
            auto uuid = it.key().toUuid();
            if (project->samples.find(uuid) != project->samples.end()) continue; // we already have this; next
            auto s = Sample::fromCbor(it.value(), uuid);
            s->project = project;
            project->samples.insert(s->uuid, s);
        }
        emit project->socket->updatePatternLists();
    }

    return Node::fromCbor(root.at(2), parent); // let Node handle the rest
}

void FileOps::loadConfig() {
    QFile file(Config::Directories::configFile);
    if (file.open(QFile::ReadOnly)) { // file exists! read in
        QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
        auto root = doc.object();
        file.close();

        if (auto dirs = root["directories"].toObject(); !dirs.isEmpty()) {
            if (auto s = dirs["projects"].toString(); !s.isNull()) Config::Directories::projects = s;
            if (auto s = dirs["presets"].toString(); !s.isNull()) Config::Directories::presets = s;
        }

    }

    // make sure directories exist
    if (auto d = QDir(Config::Directories::projects); !d.exists()) d.mkpath(".");
    if (auto d = QDir(Config::Directories::presets); !d.exists()) d.mkpath(".");
}

void FileOps::saveConfig() {
    QFileInfo fi(Config::Directories::configFile);
    fi.dir().mkpath("."); // make sure directory exists

    QFile file(fi.filePath());
    if (!file.open(QFile::WriteOnly)) return;

    QJsonDocument doc;
    QJsonObject root;

    {
        QJsonObject dirs;

        dirs["projects"] = Config::Directories::projects;
        dirs["presets"] = Config::Directories::presets;

        root["directories"] = dirs;
    }

    doc.setObject(root);
    file.write(doc.toJson(QJsonDocument::Indented));
    file.close();
}
