// Written by James Turner, started October 2017
//
// SPDX-FileCopyrightText: 2017 James Turner
// SPDX-License-Identifier: GPL-2.0-or-later

#include "config.h"

#include "LocalAircraftCache.hxx"

#include <cassert>

#include <QDir>
#include <QThread>
#include <QMutex>
#include <QMutexLocker>
#include <QDataStream>
#include <QMap>
#include <QSettings>
#include <QDebug>

#include <Main/globals.hxx>
#include <Main/locale.hxx>
#include <Main/sentryIntegration.hxx>

#include <simgear/misc/ResourceManager.hxx>
#include <simgear/misc/sg_path.hxx>
#include <simgear/props/props.hxx>
#include <simgear/props/props_io.hxx>
#include <simgear/structure/exception.hxx>

#include "AircraftCompatibility.hxx"
#include "SettingsWrapper.hxx"

const std::vector<QByteArray> static_localizedStringTags = {"name", "desc"};

QDataStream& operator<<(QDataStream& ds, const AircraftItem::LocalizedStrings& ls)
{
    ds << ls.locale << ls.strings;
    return ds;
}

QDataStream& operator>>(QDataStream& ds, AircraftItem::LocalizedStrings& ls)
{
    ds >> ls.locale >> ls.strings;
    return ds;
}

bool AircraftItem::initFromFile(QDir dir, QString filePath)
{
    SGPropertyNode root;
    readProperties(filePath.toStdString(), &root);

    if (!root.hasChild("sim")) {
        qWarning() << "-set.xml has no <sim> element" << filePath;
        return false;
    }

    SGPropertyNode_ptr sim = root.getNode("sim");

    path = filePath;
    pathModTime = QFileInfo(path).lastModified();
    if (sim->getBoolValue("exclude-from-gui", false)) {
        excluded = true;
        return false;
    }

    LocalizedStrings ls;
    ls.locale = "en";
    ls.strings["name"] = QString::fromStdString(sim->getStringValue("description")).trimmed();
    authors = QString::fromStdString(sim->getStringValue("author"));

    if (sim->hasChild("rating")) {
        SGPropertyNode_ptr ratingsNode = sim->getNode("rating");
        for (int i=0; i< 4; ++i) {
            ratings[i] = LocalAircraftCache::ratingFromProperties(ratingsNode, i);
        }
    }

    if (sim->hasChild("long-description")) {
        // clean up any XML whitespace in the text.
        ls.strings["desc"] = QString::fromStdString(sim->getStringValue("long-description")).simplified();
    }

    if (sim->hasChild("variant-of")) {
        variantOf = QString::fromStdString(sim->getStringValue("variant-of"));
    } else {
        isPrimary = true;
    }

    if (sim->hasChild("primary-set")) {
        isPrimary = sim->getBoolValue("primary-set");
    }

    if (sim->hasChild("tags")) {
        SGPropertyNode_ptr tagsNode = sim->getChild("tags");
        int nChildren = tagsNode->nChildren();
        for (int i = 0; i < nChildren; i++) {
            const SGPropertyNode* c = tagsNode->getChild(i);
            if (c->getNameString() == "tag") {
                std::string tagName = c->getStringValue();
                usesHeliports |= (tagName == "helicopter");
                // could also consider vtol tag?
                usesSeaports |= (tagName == "seaplane");
                usesSeaports |= (tagName == "floats");
                needsMaintenance |= (tagName == "needs-maintenance");

                // and actually store the tags
                tags.push_back(QString::fromStdString(tagName));
            }
        } // of tags iteration
    } // of set-xml has tags

    if (sim->hasChild("previews")) {
        SGPropertyNode_ptr previewsNode = sim->getChild("previews");
        const QString aircraftPrefix = "Aircraft/" + dir.dirName() + "/";

        for (auto previewNode : previewsNode->getChildren("preview")) {
            // add file path as url
            QString pathInXml = QString::fromStdString(previewNode->getStringValue("path"));

            // https://sourceforge.net/p/flightgear/codetickets/2644/
            // allow Aircraft/ prefixed paths in previews; what we're actually doing here
            // is trimming them back to the style we expect
            if (pathInXml.startsWith(aircraftPrefix)) {
                pathInXml = pathInXml.mid(aircraftPrefix.length());
            }

            QString previewPath = dir.absoluteFilePath(pathInXml);

            if (!QFile::exists(previewPath)) {
                qWarning() << "Missing local preview file" << previewPath;
                continue;
            }

            previews.append(QUrl::fromLocalFile(previewPath));

        }
    }

    if (sim->hasChild("thumbnail")) {
        thumbnailPath = QString::fromStdString(sim->getStringValue("thumbnail"));
    } else {
        thumbnailPath = "thumbnail.jpg";
    }

    declaredCompatible = isAircraftCompatible(sim);
    homepageUrl = QUrl(QString::fromStdString(sim->getStringValue("urls/home-page")));
    supportUrl = QUrl(QString::fromStdString(sim->getStringValue("urls/support")));
    wikipediaUrl = QUrl(QString::fromStdString(sim->getStringValue("urls/wikipedia")));
    communityUrl = QUrl(QString::fromStdString(sim->getStringValue("urls/community")));

    _localized.push_front(ls);
    readLocalizedStrings(sim);
    doLocalizeStrings();

    return true;
}

void AircraftItem::readLocalizedStrings(SGPropertyNode_ptr simNode)
{
    if (!simNode->hasChild("localized"))
        return;

    auto localeNode = simNode->getChild("localized");
    const auto num = localeNode->nChildren();
    for (int i = 0; i < num; i++) {
        const SGPropertyNode* c = localeNode->getChild(i);

        LocalizedStrings ls;
        ls.locale = QString::fromStdString(c->getNameString());
        if (c->hasChild("description")) {
            ls.strings["name"] = QString::fromStdString(c->getStringValue("description"));
        }
        if (c->hasChild("long-description")) {
            ls.strings["desc"] = QString::fromStdString(c->getStringValue("long-description")).simplified();
        }

        _localized.push_back(ls);
    }
}

void AircraftItem::doLocalizeStrings()
{
    // default strings are always at the front
    _currentStrings = _localized.front().strings;

    const auto lang = QString::fromStdString(globals->get_locale()->getPreferredLanguage());
    // find the matching locale
    auto it = std::find_if(_localized.begin(), _localized.end(), [lang](const LocalizedStrings& ls) {
        return ls.locale == lang;
    });

    if (it == _localized.end())
        return; // nothing else to do

    for (auto t : static_localizedStringTags) {
        if (it->strings.contains(t)) {
            // copy the value we found
            _currentStrings[t] = it->strings.value(t);
        }
    } // of strings iteration
}

QString AircraftItem::name() const
{
    return _currentStrings.value("name");
}

QString AircraftItem::description() const
{
    return _currentStrings.value("desc");
}

QString AircraftItem::baseName() const
{
    QString fn = QFileInfo(path).fileName();
    fn.truncate(fn.size() - 8);
    return fn;
}

void AircraftItem::fromDataStream(QDataStream& ds)
{
    ds >> path >> pathModTime >> excluded;
    if (excluded) {
        return;
    }

    ds >> authors >> variantOf >> isPrimary;
    for (int i=0; i<4; ++i) ds >> ratings[i];
    ds >> previews;
    ds >> thumbnailPath;
    ds >> declaredCompatible;
    ds >> needsMaintenance >> usesHeliports >> usesSeaports;
    ds >> homepageUrl >> supportUrl >> wikipediaUrl >> communityUrl;
    ds >> tags;
    ds >> _localized;

    doLocalizeStrings();
}

void AircraftItem::toDataStream(QDataStream& ds) const
{
    ds << path << pathModTime << excluded;
    if (excluded) {
        return;
    }

    ds << authors << variantOf << isPrimary;
    for (int i=0; i<4; ++i) ds << ratings[i];
    ds << previews;
    ds << thumbnailPath;
    ds << declaredCompatible;
    ds << needsMaintenance << usesHeliports << usesSeaports;
    ds << homepageUrl << supportUrl << wikipediaUrl << communityUrl;
    ds << tags;
    ds << _localized;
}

int AircraftItem::indexOfVariant(QUrl uri) const
{
    const QString path = uri.toLocalFile();
    for (int i=0; i< variants.size(); ++i) {
        if (variants.at(i)->path == path) {
            return i;
        }
    }

    return -1;
}

QVariant AircraftItem::status(int variant)
{
    Q_UNUSED(variant)
    if (needsMaintenance) {
        return LocalAircraftCache::AircraftUnmaintained;
    }

    if (declaredCompatible) {
        return LocalAircraftCache::AircraftOk;
    } else {
        return LocalAircraftCache::AircraftIncompatible;
    }
}

namespace {

static std::unique_ptr<LocalAircraftCache> static_cacheInstance;


// ensure references to Aircraft/foo and <my-aircraft-dir>/foo are resolved. This happens when
// aircraft reference a path (probably to themselves) in their -set.xml
class ScanDirProvider : public simgear::ResourceProvider
{
public:
    ScanDirProvider() : simgear::ResourceProvider(simgear::ResourceManager::PRIORITY_NORMAL) {}

    ~ScanDirProvider() = default;

    SGPath resolve(const std::string& aResource, SGPath& aContext) const override
    {
        Q_UNUSED(aContext)

        SGPath ap = _currentAircraftPath / aResource;
        if (ap.exists())
            return ap;

        string_list pieces(sgPathBranchSplit(aResource));
        if ((pieces.size() < 3) || (pieces.front() != "Aircraft")) {
            return SGPath{}; // not an Aircraft path
        }

        const std::string res(aResource, 9); // resource path with 'Aircraft/' removed
        SGPath p = _currentScanPath / res;
        if (p.exists())
            return p;

        return SGPath{};
    }

    void setCurrentPath(const SGPath& p)
    {
        _currentScanPath = p;
    }

    void setCurrentAircraftPath(const SGPath& p)
    {
        _currentAircraftPath = p;
    }

    std::vector<SGPath> findAllOfType(simgear::ResourceManager::FileType type) const override
    {
        return {};
    }

private:
    SGPath _currentScanPath;
    SGPath _currentAircraftPath;
};

class OtherAircraftDirsProvider : public simgear::ResourceProvider
{
public:
    OtherAircraftDirsProvider() : simgear::ResourceProvider(simgear::ResourceManager::PRIORITY_NORMAL) {}

    ~OtherAircraftDirsProvider() = default;

    SGPath resolve(const std::string& aResource, SGPath& aContext) const override
    {
        if (aResource.find("Aircraft/") != 0) {
            return SGPath{}; // not an aircraft path
        }

        const std::string res(aResource, 9); // resource path with 'Aircraft/' removed

        Q_UNUSED(aContext)
        QStringList paths = LocalAircraftCache::instance()->paths();
        Q_FOREACH(auto p, paths) {
            const auto sp = SGPath::fromUtf8(p.toUtf8().toStdString()) / res;
           // qWarning() << "OADP: trying:" << QString::fromStdString(sp.utf8Str());
            if (sp.exists())
                return sp;
        }

        return SGPath{};
    }

    std::vector<SGPath> findAllOfType(simgear::ResourceManager::FileType type) const override
    {
        return {};
    }
};

class AircraftScanThread : public QThread
{
    Q_OBJECT
public:
    AircraftScanThread(QStringList dirsToScan) :
        m_dirs(dirsToScan),
        m_done(false)
    {
        auto rm = simgear::ResourceManager::instance();
        m_currentScanDir.reset(new ScanDirProvider);
        rm->addProvider(m_currentScanDir.get());
    }

    ~AircraftScanThread()
    {
        simgear::ResourceManager::instance()->removeProvider(m_currentScanDir.get());
    }

    /** thread-safe access to items already scanned */
    QVector<AircraftItemPtr> items()
    {
        QVector<AircraftItemPtr> result;
        QMutexLocker g(&m_lock);
        result.swap(m_items);
        Q_ASSERT(m_items.empty());
        g.unlock();
        return result;
    }

    void setDone()
    {
        m_done = true;
    }

Q_SIGNALS:
    void addedItems();

protected:
    void run() override
    {
        flightgear::addSentryBreadcrumb("AircraftScan started", "info");
        readCache();

        Q_FOREACH(QString d, m_dirs) {
            const auto p = SGPath::fromUtf8(d.toUtf8().toStdString());
            m_currentScanDir->setCurrentPath(p);
            scanAircraftDir(QDir(d));
            if (m_done) {
                return;
            }
        }

        writeCache();
        flightgear::addSentryBreadcrumb("AircraftScan finished", "info");
    }

private:
    void readCache()
    {
        auto settings = flightgear::getQSettings();
        QByteArray cacheData = settings.value("aircraft-cache").toByteArray();
        if (!cacheData.isEmpty()) {
            QDataStream ds(cacheData);
            quint32 count;
            QString cacheVersion;
            ds >> cacheVersion >> count;

            if (cacheVersion != FLIGHTGEAR_MAJOR_MINOR_VERSION) {
                return; // mis-matched cache, version, drop
            }

            for (quint32 i = 0; i < count; ++i) {
                AircraftItemPtr item(new AircraftItem);
                item->fromDataStream(ds);

                QFileInfo finfo(item->path);
                if (finfo.exists() && (finfo.lastModified() == item->pathModTime)) {
                    // corresponding -set.xml file still exists and is
                    // unmodified
                    m_cachedItems[item->path] = item;
                }
            } // of cached item iteration
        }
    }

    void writeCache()
    {
        auto settings = flightgear::getQSettings();
        QByteArray cacheData;
        {
            QDataStream ds(&cacheData, QIODevice::WriteOnly);
            quint32 count = static_cast<quint32>(m_nextCache.count());

            // Cache version is tied to the Major and Minor version of the release as the cache
            // stores aircraft compatibility with specific releases.  We assume that aircraft
            // compatibility is constant across patches.
            ds << FLIGHTGEAR_MAJOR_MINOR_VERSION << count;

            Q_FOREACH(AircraftItemPtr item, m_nextCache.values()) {
                item->toDataStream(ds);
            }
        }

        settings.setValue("aircraft-cache", cacheData);
    }

    void scanAircraftDir(QDir path)
    {
        //QTime t;
        //t.start();

        QStringList filters;
        filters << "*-set.xml";
        Q_FOREACH(QFileInfo child, path.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) {
            if (m_done) { // thread termination bail-out
                return;
            }

            QDir childDir(child.absoluteFilePath());
            QMap<QString, AircraftItemPtr> baseAircraft;
            QList<AircraftItemPtr> variants;

            // ensure aircraft dir is available to simgear::ResourceProvider
            // otherwise some aircraft -set.xml includes fail
            const auto p = SGPath::fromUtf8(child.absoluteFilePath().toUtf8().toStdString());
            m_currentScanDir->setCurrentAircraftPath(p);

            Q_FOREACH(QFileInfo xmlChild, childDir.entryInfoList(filters, QDir::Files)) {
                try {
                    QString absolutePath = xmlChild.absoluteFilePath();
                    AircraftItemPtr item;

                    // should we re-stat here? But we already did so when loading
                    // the cache and dropped any non-valid entries
                    if (m_cachedItems.contains(absolutePath)) {
                        item = m_cachedItems.value(absolutePath);
                    } else {
                        // scan the cached item
                        item = AircraftItemPtr(new AircraftItem);
                        bool ok = item->initFromFile(childDir, absolutePath);
                        if (!ok) {
                            continue;
                        }
                    }

                    m_nextCache[absolutePath] = item;

                    if (item->excluded) {
                        continue;
                    }

                    if (item->isPrimary) {
                        baseAircraft.insert(item->baseName(), item);
                    } else {
                        variants.append(item);
                    }
                } catch (sg_exception& e) {
                    qWarning() << "Problems occurred while parsing" << xmlChild.absoluteFilePath() << "(skipping)"
                               << "\n\t" << QString::fromStdString(e.what());
                    continue;
                }

                if (m_done) { // thread termination bail-out
                    return;
                }
            } // of set.xml iteration

            // bind variants to their principals
            Q_FOREACH(AircraftItemPtr item, variants) {
                if (!baseAircraft.contains(item->variantOf)) {
                    qWarning() << "can't find principal aircraft " << item->variantOf << " for variant:" << item->path;
                    continue;
                }

                baseAircraft.value(item->variantOf)->variants.append(item);
            }

            // lock mutex while we modify the items array
            bool didAddItems = false;
            {
                QMutexLocker g(&m_lock);
                m_items+=(baseAircraft.values().toVector());
                didAddItems = !m_items.isEmpty();
            }

            if (didAddItems) {
                emit addedItems();
            }
        } // of subdir iteration

        //qInfo() << "scanning" << path.absolutePath() << "took" << t.elapsed();
    }

    QMutex m_lock;
    QStringList m_dirs;
    QVector<AircraftItemPtr> m_items;

    QMap<QString, AircraftItemPtr > m_cachedItems;
    QMap<QString, AircraftItemPtr > m_nextCache;

    bool m_done;
    std::unique_ptr<ScanDirProvider> m_currentScanDir;
};

} // of anonymous namespace


class LocalAircraftCache::AircraftCachePrivate
{
public:
    QStringList m_paths;
    std::unique_ptr<AircraftScanThread> m_scanThread;
    QVector<AircraftItemPtr> m_items;
    std::unique_ptr<OtherAircraftDirsProvider> m_otherDirsProvider;
};

LocalAircraftCache* LocalAircraftCache::instance()
{
    if (!static_cacheInstance) {
        static_cacheInstance.reset(new LocalAircraftCache);
    }

    return static_cacheInstance.get();
}

void LocalAircraftCache::reset()
{
    static_cacheInstance.reset();
}

LocalAircraftCache::LocalAircraftCache() :
    d(new AircraftCachePrivate)
{
    d->m_otherDirsProvider.reset(new OtherAircraftDirsProvider);
    auto rm = simgear::ResourceManager::instance();
    rm->addProvider(d->m_otherDirsProvider.get());
}

LocalAircraftCache::~LocalAircraftCache()
{
    abandonCurrentScan();
    if (simgear::ResourceManager::haveInstance()) {
        simgear::ResourceManager::instance()->removeProvider(d->m_otherDirsProvider.get());
    } else {
        // resource manager will already have destroyed the provider. Awkward
        // ownership model :(
        d->m_otherDirsProvider.release();
    }
}

void LocalAircraftCache::setPaths(QStringList paths)
{
    if (paths == d->m_paths) {
        return;
    }

    d->m_items.clear();
    emit cleared();
    d->m_paths = paths;
}

QStringList LocalAircraftCache::paths() const
{
    return d->m_paths;
}

void LocalAircraftCache::scanDirs()
{
    abandonCurrentScan();
    d->m_items.clear();

    QStringList dirs = d->m_paths;

    for (SGPath ap : globals->get_aircraft_paths()) {
        dirs << QString::fromStdString(ap.utf8Str());
    }

    SGPath rootAircraft = globals->get_fg_root() / "Aircraft";
    dirs << QString::fromStdString(rootAircraft.utf8Str());

    d->m_scanThread.reset(new AircraftScanThread(dirs));
    connect(d->m_scanThread.get(), &AircraftScanThread::finished, this,
            &LocalAircraftCache::onScanFinished);
    // force a queued connection here since we the scan thread object still
    // belongs to the same thread as us, and hence this would otherwise be
    // a direct connection
    connect(d->m_scanThread.get(), &AircraftScanThread::addedItems,
            this, &LocalAircraftCache::onScanResults,
            Qt::QueuedConnection);
    d->m_scanThread->start();

    emit scanStarted();
}

int LocalAircraftCache::itemCount() const
{
    return d->m_items.size();
}

QVector<AircraftItemPtr> LocalAircraftCache::allItems() const
{
    return d->m_items;
}

AircraftItemPtr LocalAircraftCache::itemAt(int index) const
{
    return d->m_items.at(index);
}

int LocalAircraftCache::findIndexWithUri(QUrl aircraftUri) const
{
    QString path = aircraftUri.toLocalFile();
    for (int row=0; row < d->m_items.size(); ++row) {
        const AircraftItemPtr item(d->m_items.at(row));
        if (item->path == path) {
            return row;
        }

        // check variants too
        for (int vr=0; vr < item->variants.size(); ++vr) {
            if (item->variants.at(vr)->path == path) {
                return row;
            }
        }
    }

    return -1;
}

AircraftItemPtr LocalAircraftCache::primaryItemFor(AircraftItemPtr item) const
{
    if (!item || item->variantOf.isEmpty())
        return item;

    for (int row=0; row < d->m_items.size(); ++row) {
        const AircraftItemPtr p(d->m_items.at(row));
        if (p->baseName() == item->variantOf) {
            return p;
        }
    }

    return {};
}

AircraftItemPtr LocalAircraftCache::findItemWithUri(QUrl aircraftUri) const
{
    int index = findIndexWithUri(aircraftUri);
    if (index >= 0) {
        return d->m_items.at(index);
    }

    return {};
}

void LocalAircraftCache::abandonCurrentScan()
{
    if (d->m_scanThread) {
        // don't fire onScanFinished when the thread ends
        disconnect(d->m_scanThread.get(), &AircraftScanThread::finished, this,
                &LocalAircraftCache::onScanFinished);

        d->m_scanThread->setDone();
        if (!d->m_scanThread->wait(32000)) {
            qWarning() << Q_FUNC_INFO << "scan thread failed to terminate";
        }
        d->m_scanThread.reset();
    }
}


void LocalAircraftCache::onScanResults()
{
    if (!d->m_scanThread) {
        return;
    }

    QVector<AircraftItemPtr> newItems = d->m_scanThread->items();
    if (newItems.isEmpty())
        return;

    d->m_items+=newItems;
    emit addedItems(newItems.size());
}

void LocalAircraftCache::onScanFinished()
{
    d->m_scanThread.reset();
    emit scanCompleted();
}

bool LocalAircraftCache::isCandidateAircraftPath(QString path)
{
    QStringList filters;
    filters << "*-set.xml";
    // count of child dirs, if we visited more than ten children without
    // finding a -set.xml file, let's assume we are done. This avoids an
    // exhaustive search of huge directory trees
    int dirCount = 0,
        setXmlCount = 0;

    QDir d(path);
    Q_FOREACH(QFileInfo child, d.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) {
        QDir childDir(child.absoluteFilePath());
        ++dirCount;
        Q_FOREACH(QFileInfo xmlChild, childDir.entryInfoList(filters, QDir::Files)) {
            ++setXmlCount;
        }

        if ((setXmlCount > 0) || (dirCount > 10)) {
            break;
        }
    }

    return (setXmlCount > 0);
}

int LocalAircraftCache::ratingFromProperties(SGPropertyNode* node, int ratingIndex)
{
    const char* names[] = {"FDM", "systems", "cockpit", "model"};
    assert((ratingIndex >= 0) && (ratingIndex < 4));
    return node->getIntValue(names[ratingIndex]);
}

LocalAircraftCache::ParseSetXMLResult
LocalAircraftCache::readAircraftProperties(const SGPath &setPath, SGPropertyNode_ptr props)
{
    // it would be race-y to touch the resource provider while the scan thread is running
    // and our provider would confuse current-aircraft-dir lookups as well. Since we
    // can't do thread-specific resource providers, we just bail here.
    if (d->m_scanThread) {
        return ParseSetXMLResult::Retry;
    }

    auto rm = simgear::ResourceManager::instance();

    // ensure aircraft relative paths in the -set.xml parsing work

    std::unique_ptr<ScanDirProvider> dp{new ScanDirProvider};
    rm->addProvider(dp.get());

    // we want to know the aircraft directory the aircraft lives in. This might
    // be a manually added path (for local aircraft) or the install dir for the
    // hangar (for packaged aircraft). Because -set.xml files are always found at
    // /some/path/foobarAircraft/<aircraft-name>/some-set.xml, and the path we
    // we want here is /some/path/foodbarAircraft, we use dirPath twice, and this
    // works any kind of installed aircraft
    const SGPath aircraftDirPath = setPath.dirPath().dirPath();
    dp->setCurrentPath(aircraftDirPath);

    dp->setCurrentAircraftPath(setPath);

    ParseSetXMLResult result = ParseSetXMLResult::Failed;

    try {
        readProperties(setPath, props);
        result = ParseSetXMLResult::Ok;
    } catch (sg_exception& e) {
        // malformed include or XML
        qWarning() << "Problems occurred while parsing" << QString::fromStdString(setPath.utf8Str())
                   << "\n\t" << QString::fromStdString(e.getFormattedMessage());
    }

    rm->removeProvider(dp.get());
    return result;
}

#include "LocalAircraftCache.moc"
