// Copyright (C) 2016 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only

#include <QtTest/QtTest>
#include <QCoreApplication>
#include <QJsonDocument>

#include <QtScxml/qscxmlcompiler.h>
#include <QtScxml/qscxmlstatemachine.h>

#include <functional>

#include "scxml/scion.h"
#include "scxml/compiled_tests.h"

Q_DECLARE_METATYPE(std::function<QScxmlStateMachine *()>);

enum { SpyWaitTime = 12000 };

static QSet<QString> testFailOnRun = QSet<QString>()
        // Currently we do not support loading data as XML content inside the <data> tag.
        << QLatin1String("w3c-ecma/test557.txml")
        // The following test uses the undocumented "exmode" attribute.
        << QLatin1String("w3c-ecma/test441a.txml")
        // The following test needs manual inspection of the result. However, note that we do not support the undocumented "exmode" attribute.
        << QLatin1String("w3c-ecma/test441b.txml")
        // The following test needs manual inspection of the result.
        // The following test needs manual inspection of the result. However, note that we do not support multiple identical keys for event data.
        << QLatin1String("w3c-ecma/test178.txml")
        // We do not support the optional basic http event i/o processor.
        << QLatin1String("w3c-ecma/test201.txml")
        << QLatin1String("w3c-ecma/test230.txml")
        << QLatin1String("w3c-ecma/test250.txml")
        << QLatin1String("w3c-ecma/test307.txml")
        // Qt does not support forcing initial states that are not marked as such.
        << QLatin1String("w3c-ecma/test413.txml") // FIXME: verify initial state setting...
        << QLatin1String("w3c-ecma/test576.txml") // FIXME: verify initial state setting...
        // Scion apparently sets <data> values without a src/expr attribute to 0. We set it to undefined, as specified in B.2.1.
        << QLatin1String("w3c-ecma/test456.txml") // replaced by modified_test456
        // FIXME: qscxmlc fails on improper scxml file, currently no way of testing it properly for compiled case
        << QLatin1String("w3c-ecma/test301.txml")
        // FIXME: Currently we do not support nested scxml as a child of assign.
        << QLatin1String("w3c-ecma/test530.txml")
        ;

class MySignalSpy: public QSignalSpy
{
public:
    explicit MySignalSpy(const QObject *obj, const char *aSignal)
        : QSignalSpy(obj, aSignal)
    {}

    bool fastWait()
    {
        const int increment = SpyWaitTime / 20;
        for (int total = 0; total < SpyWaitTime; total += increment) {
            if (this->wait(increment))
                return true;
        }
        return false;
    }
};

class DynamicLoader: public QScxmlCompiler::Loader
{
public:
    DynamicLoader();
    QByteArray load(const QString &name,
                    const QString &baseDir,
                    QStringList *errors) override final;

};

DynamicLoader::DynamicLoader()
    : Loader()
{}

QByteArray DynamicLoader::load(const QString &name,
                               const QString &baseDir,
                               QStringList *errors)
{
    QStringList errs;
    QByteArray contents;
    QUrl url(name);
    if (!url.isLocalFile() && !url.isRelative())
        errs << QStringLiteral("src attribute is not a local file (%1)").arg(name);
    QFileInfo fInfo(url.isLocalFile() ? url.toLocalFile() : name);
    if (fInfo.isRelative())
        fInfo = QFileInfo(QDir(baseDir).filePath(fInfo.filePath()));
    fInfo = QFileInfo(QLatin1String(":/") + fInfo.filePath()); // take it from resources

    if (!fInfo.exists()) {
        errs << QStringLiteral("src attribute resolves to non existing file (%1)")
                 .arg(fInfo.absoluteFilePath());
    } else {
        QFile f(fInfo.absoluteFilePath());
        if (f.open(QFile::ReadOnly))
            contents = f.readAll();
        else
            errs << QStringLiteral("Failure opening file %1: %2")
                               .arg(fInfo.absoluteFilePath(), f.errorString());
    }
    if (errors)
        *errors = errs;

    return contents;
}


class TestScion: public QObject
{
    Q_OBJECT

private slots:
    void initTestCase();
    void dynamic_data();
    void dynamic();
    void compiled_data();
    void compiled();

private:
    void generateData();
    bool runTest(QScxmlStateMachine *stateMachine, const QJsonObject &testDescription);
};

void TestScion::initTestCase()
{
}

enum TestStatus {
    TestIsOk,
    TestFailsOnRun
};
Q_DECLARE_METATYPE(TestStatus)

void TestScion::generateData()
{
    QTest::addColumn<QString>("scxml");
    QTest::addColumn<QString>("json");
    QTest::addColumn<TestStatus>("testStatus");
    QTest::addColumn<std::function<QScxmlStateMachine *()>>("creator");

    const int nrOfTests = sizeof(testBases) / sizeof(const char *);
    for (int i = 0; i < nrOfTests; ++i) {
        TestStatus testStatus;
        QString base = QString::fromUtf8(testBases[i]);
        if (testFailOnRun.contains(base))
            testStatus = TestFailsOnRun;
        else
            testStatus = TestIsOk;
        QTest::newRow(testBases[i]) << base + QLatin1String(".scxml")
                                    << base + QLatin1String(".json")
                                    << testStatus
                                    << creators[i];
    }
}

void TestScion::dynamic_data()
{
    generateData();
}

void TestScion::dynamic()
{
    QFETCH(QString, scxml);
    QFETCH(QString, json);
    QFETCH(TestStatus, testStatus);
    QFETCH(std::function<QScxmlStateMachine *()>, creator);

    QFile jsonFile(QLatin1String(":/") + json);
    QVERIFY(jsonFile.open(QIODevice::ReadOnly));
    auto testDescription = QJsonDocument::fromJson(jsonFile.readAll());
    jsonFile.close();
    QVERIFY(testDescription.isObject());

    QFile scxmlFile(QLatin1String(":/") + scxml);
    QVERIFY(scxmlFile.open(QIODevice::ReadOnly));
    QXmlStreamReader xmlReader(&scxmlFile);
    QScxmlCompiler compiler(&xmlReader);
    compiler.setFileName(scxml);
    DynamicLoader loader;
    compiler.setLoader(&loader);
    QScopedPointer<QScxmlStateMachine> stateMachine(compiler.compile());
    QVERIFY(compiler.errors().isEmpty());
    scxmlFile.close();

    QVERIFY(stateMachine != nullptr);
    stateMachine->setLoader(&loader);

    const bool runResult = runTest(stateMachine.data(), testDescription.object());
    if (runResult == false && testStatus == TestFailsOnRun)
        QEXPECT_FAIL("", "This is expected to fail on run", Abort);

    QVERIFY(runResult);
    QCoreApplication::processEvents(); // flush any pending events
}

static QStringList getStates(const QJsonObject &obj, const QString &key)
{
    QStringList states;
    auto jsonStates = obj.value(key).toArray();
    for (int i = 0, ei = jsonStates.size(); i != ei; ++i) {
        QString state = jsonStates.at(i).toString();
        Q_ASSERT(!state.isEmpty());
        states.append(state);
    }
    std::sort(states.begin(), states.end());
    return states;
}

void TestScion::compiled_data()
{
    generateData();
}

void TestScion::compiled()
{
    QFETCH(QString, scxml);
    QFETCH(QString, json);
    QFETCH(TestStatus, testStatus);
    QFETCH(std::function<QScxmlStateMachine *()>, creator);

    QFile jsonFile(QLatin1String(":/") + json);
    QVERIFY(jsonFile.open(QIODevice::ReadOnly));
    auto testDescription = QJsonDocument::fromJson(jsonFile.readAll());
    jsonFile.close();

    QScopedPointer<QScxmlStateMachine> stateMachine(creator());
    if (stateMachine == nullptr && testStatus == TestFailsOnRun) {
        QEXPECT_FAIL("", "This is expected to fail", Abort);
    }
    QVERIFY(stateMachine != nullptr);
    DynamicLoader loader;
    stateMachine->setLoader(&loader);

    const bool runResult = runTest(stateMachine.data(), testDescription.object());
    if (runResult == false && testStatus == TestFailsOnRun)
        QEXPECT_FAIL("", "This is expected to fail on run", Abort);

    QVERIFY(runResult);

    QCoreApplication::processEvents(); // flush any pending events
}

static bool verifyStates(QScxmlStateMachine *stateMachine, const QJsonObject &stateDescription, const QString &key, int counter)
{
    const auto errorMessage = [&key, &counter](const QStringList& current,
                                     const QStringList& expected, const QLatin1String& details) {
        qWarning("Incorrect %s (%d)!", qPrintable(key), counter);
        qWarning() << "Current configuration:" << current;
        qWarning() << "Expected configuration:" << expected;
        qWarning() << "Failed state read was done with:" << details;
    };
    using namespace Qt::StringLiterals;

    // Verify that activeStateNames() matches the expectation
    auto current = stateMachine->activeStateNames();
    std::sort(current.begin(), current.end());
    auto expected = getStates(stateDescription, key);
    if (current != expected) {
        errorMessage(current, expected, "activeStateNames()"_L1);
        return false;
    }

    for (const auto& s : expected) {
        // Verify that isActive(stateName) matches the expectation
        if (!stateMachine->isActive(s)) {
            errorMessage(current, expected, "isActive()"_L1);
            return false;
        }
        // Verify that the metaobject matches the expectation
        const auto mo = stateMachine->metaObject();
        if (!mo->property(mo->indexOfProperty(s.toLocal8Bit())).read(stateMachine).toBool()) {
            errorMessage(current, expected, "metaobject read"_L1);
            return false;
        }
    }
    return true;
}

static bool playEvent(QScxmlStateMachine *stateMachine, const QJsonObject &eventDescription, int counter)
{
    if (!stateMachine->isRunning()) {
        qWarning() << "State machine stopped running!";
        return false;
    }

    Q_ASSERT(eventDescription.contains(QLatin1String("event")));
    auto event = eventDescription.value(QLatin1String("event")).toObject();
    auto eventName = event.value(QLatin1String("name")).toString();
    Q_ASSERT(!eventName.isEmpty());
    QScxmlEvent::EventType type = QScxmlEvent::ExternalEvent;
    if (event.contains(QLatin1String("type"))) {
        QString typeStr = event.value(QLatin1String("type")).toString();
        if (typeStr.compare(QLatin1String("external"), Qt::CaseInsensitive) == 0)
            type = QScxmlEvent::InternalEvent;
        else if (typeStr.compare(QLatin1String("platform"), Qt::CaseInsensitive) == 0)
            type = QScxmlEvent::PlatformEvent;
        else {
            qWarning() << "unexpected event type in " << eventDescription;
            return false;
        }
    }
    QVariant data;
    // remove ifs and rely on defaults?
    if (event.contains(QLatin1String("data"))) {
        data = event.value(QLatin1String("data")).toVariant();
    }
    QString sendid;
    if (event.contains(QLatin1String("sendid")))
        sendid = event.value(QLatin1String("sendid")).toString();
    QString origin;
    if (event.contains(QLatin1String("origin")))
        origin = event.value(QLatin1String("origin")).toString();
    QString origintype;
    if (event.contains(QLatin1String("origintype")))
        origintype = event.value(QLatin1String("origintype")).toString();
    QString invokeid;
    if (event.contains(QLatin1String("invokeid")))
        invokeid = event.value(QLatin1String("invokeid")).toString();
    QScxmlEvent *e = new QScxmlEvent;
    e->setName(eventName);
    e->setEventType(type);
    e->setData(data);
    e->setSendId(sendid);
    e->setOrigin(origin);
    e->setOriginType(origintype);
    e->setInvokeId(invokeid);
    int delay = 0;
    if (eventDescription.contains(QLatin1String("after"))) {
        delay = eventDescription.value(QLatin1String("after")).toInt();
        Q_ASSERT(delay > 0);
        e->setDelay(delay);
    }

    QTimer trigger;
    trigger.setSingleShot(true);
    stateMachine->connectToEvent(eventName, &trigger, [&stateMachine, &trigger](const QScxmlEvent &) {
        QObject::connect(stateMachine, &QScxmlStateMachine::reachedStableState,
                         &trigger, QOverload<>::of(&QTimer::start));
    });
    MySignalSpy triggerSpy(&trigger, SIGNAL(timeout()));

    // Create a signal spy for each state we expect to be (de)activated in next transition.
    using namespace Qt::StringLiterals;
    const auto expectedActive = getStates(eventDescription, QLatin1String("nextConfiguration"));
    std::list<std::unique_ptr<QSignalSpy>> stateSpies;
    const auto mo = stateMachine->metaObject();

    for (const auto& s : stateMachine->stateNames()) {
        if (expectedActive.contains(s) == stateMachine->isActive(s))
            continue;
        // The state is expected to undergo an activation change, create a signal spy
        const auto changedSignal = s + "Changed(bool)"_L1;
        auto signalIndex = mo->indexOfSignal(changedSignal.toUtf8().constData());
        stateSpies.push_back(std::make_unique<QSignalSpy>(stateMachine, mo->method(signalIndex)));
    }

    stateMachine->submitEvent(e);

    if (!triggerSpy.fastWait()) {
        qWarning() << "State machine did not reach a stable state.";
    } else if (verifyStates(stateMachine, eventDescription, QLatin1String("nextConfiguration"), counter)) {
        for (const auto& spy : stateSpies) {
            if (spy->size() != 1) {
                qWarning() << "Signal error for:" << spy->signal() << ", size:" << spy->size();
                return false;
            }
        }
        return true;
    }

    if (delay > 0)
        qWarning("... after sending delayed event %s with a delay of %dms.", qPrintable(eventName), delay);
    else
        qWarning("... after sending event %s.", qPrintable(eventName));
    return false;
}

static bool playEvents(QScxmlStateMachine *stateMachine, const QJsonObject &testDescription)
{
    auto jsonEvents = testDescription.value(QLatin1String("events"));
    Q_ASSERT(!jsonEvents.isNull());
    auto eventsArray = jsonEvents.toArray();
    for (int i = 0, ei = eventsArray.size(); i != ei; ++i) {
        if (!playEvent(stateMachine, eventsArray.at(i).toObject(), i + 1))
            return false;
    }
    return true;
}

QT_BEGIN_NAMESPACE
QDebug operator<<(QDebug debug, const QScxmlEvent &event)
{
    QJsonObject obj;
    obj.insert(QLatin1String("name"), event.name());
    obj.insert(QLatin1String("type"), event.eventType());
    obj.insert(QLatin1String("data"), QJsonValue::fromVariant(event.data()));
    obj.insert(QLatin1String("sendid"), event.sendId());
    obj.insert(QLatin1String("origin"), event.origin());
    obj.insert(QLatin1String("originType"), event.originType());
    obj.insert(QLatin1String("invokeid"), event.invokeId());
    return debug << obj;
}

QDebug operator<<(QDebug debug, const QList<QScxmlEvent> &events)
{
    return QtPrivate::printSequentialContainer(debug, "QList", events);
}
QT_END_NAMESPACE

static int verifyEvent(const QList<QScxmlEvent> &receivedEvents, const QJsonObject &event,
                       int position) {
    QScxmlEvent::EventType eventType = QScxmlEvent::ExternalEvent;
    const bool verifyEventType = event.contains(QLatin1String("type"));
    if (verifyEventType) {
        QString typeStr = event.value(QLatin1String("type")).toString();
        if (typeStr.compare(QLatin1String("external"), Qt::CaseInsensitive) == 0)
            eventType = QScxmlEvent::InternalEvent;
        else if (typeStr.compare(QLatin1String("platform"), Qt::CaseInsensitive) == 0)
            eventType = QScxmlEvent::PlatformEvent;
        else {
            qWarning() << "unexpected event type in " << event;
            return -1;
        }
    }

    const bool verifyName = event.contains(QLatin1String("name"));
    const QString name = verifyName ? event.value(QLatin1String("name")).toString() : QString();

    const bool verifyData = event.contains(QLatin1String("data"));
    const QVariant data = verifyData ? event.value(QLatin1String("data")).toVariant() : QVariant();
    const bool verifySendId = event.contains(QLatin1String("sendid"));
    const QString sendId = verifySendId ? event.value(QLatin1String("sendid")).toString()
                                        : QString();
    const bool verifyOrigin = event.contains(QLatin1String("origin"));
    const QString origin = verifyOrigin ? event.value(QLatin1String("origin")).toString()
                                        : QString();
    const bool verifyOriginType = event.contains(QLatin1String("originType"));
    const QString originType = verifyOriginType
            ? event.value(QLatin1String("origintype")).toString()
            : QString();
    const bool verifyInvokeId = event.contains(QLatin1String("invokeid"));
    const QString invokeId = verifyInvokeId ? event.value(QLatin1String("invokeid")).toString()
                                            : QString();

    while (position < receivedEvents.size()) {
        const QScxmlEvent &receivedEvent = receivedEvents[position];
        if ((verifyName && receivedEvent.name() != name)
                || (verifyEventType && receivedEvent.eventType() != eventType)
                || (verifyData && receivedEvent.data() != data)
                || (verifySendId && receivedEvent.sendId() != sendId)
                || (verifyOrigin && receivedEvent.origin() != origin)
                || (verifyOriginType && receivedEvent.originType() != originType)
                || (verifyInvokeId && receivedEvent.invokeId() != invokeId)) {
            ++position;
        } else {
            return position;
        }
    }

    qWarning("Did not receive expected event:");
    qWarning() << event;

    return -1; // nothing found
}

static bool verifyEvents(const QList<QScxmlEvent> &receivedEvents,
                         const QJsonObject &testDescription)
{
    auto jsonEvents = testDescription.value(QLatin1String("expectedEvents"));
    if (jsonEvents.isNull())
        return true;

    auto eventsArray = jsonEvents.toArray();

    int position = 0;
    for (int i = 0, ei = eventsArray.size(); i != ei; ++i) {
        position = verifyEvent(receivedEvents, eventsArray.at(i).toObject(), position);
        if (position < 0) {
            qWarning("received events:");
            qWarning() << receivedEvents;
            qWarning("expected events");
            qWarning() << eventsArray;
            return false;
        } else {
            ++position; // Don't use the same event twice.
        }
    }
    return true;
}

bool TestScion::runTest(QScxmlStateMachine *stateMachine, const QJsonObject &testDescription)
{
    MySignalSpy stableStateSpy(stateMachine, SIGNAL(reachedStableState()));
    MySignalSpy finishedSpy(stateMachine, SIGNAL(finished()));

    QList<QScxmlEvent> receivedEvents;
    stateMachine->connectToEvent(QLatin1String("*"), this, [&](const QScxmlEvent &event) {
        receivedEvents.append(event);
    });

    if (!stateMachine->init() && stateMachine->name() != QStringLiteral("test487")) {
        // test487 relies on a failing init to see if an error event gets posted.
        qWarning() << "init failed";
        return false;
    }
    stateMachine->start();

    if (testDescription.contains(QLatin1String("events"))
            && !testDescription.value(QLatin1String("events")).toArray().isEmpty()) {
        if (!stableStateSpy.fastWait()) {
            qWarning() << "Failed to reach stable initial state!";
            return false;
        }

        if (!verifyStates(stateMachine, testDescription, QLatin1String("initialConfiguration"), 0))
            return false;

        return playEvents(stateMachine, testDescription);
    } else {
        // Wait for all events (delayed or otherwise) to propagate.
        if (stateMachine->isRunning()) {
            finishedSpy.fastWait(); // Some tests don't have a final state, so don't check for the
                                    // result
        }
        if (!verifyEvents(receivedEvents, testDescription))
            return false;
        return verifyStates(stateMachine, testDescription, QLatin1String("initialConfiguration"), 0);
    }
}

QTEST_GUILESS_MAIN(TestScion)
#include "tst_scion.moc"
