// SPDX-License-Identifier: LGPL-3.0-or-later

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public License
* as published by the Free Software Foundation, either version 3
* of the License, or (at your option) any later version.
*
* nymea is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#include "scriptengine.h"
#include "integrations/thingmanager.h"

#include "scriptaction.h"
#include "scriptevent.h"
#include "scriptstate.h"
#include "scriptalarm.h"
#include "scriptinterfaceaction.h"
#include "scriptinterfacestate.h"
#include "scriptinterfaceevent.h"
#include "scriptthing.h"
#include "scriptthings.h"
#include "types/action.h"

#include "nymeasettings.h"
#include "logging/logengine.h"

#include <QDir>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQmlComponent>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QRegularExpression>

#include "loggingcategories.h"

NYMEA_LOGGING_CATEGORY(dcScriptEngine, "ScriptEngine")

namespace nymeaserver {
namespace scriptengine {

QList<ScriptEngine*> ScriptEngine::s_engines;
QtMessageHandler ScriptEngine::s_upstreamMessageHandler;
QLoggingCategory::CategoryFilter ScriptEngine::s_oldCategoryFilter = nullptr;
QMutex ScriptEngine::s_loggerMutex;

ScriptEngine::ScriptEngine(ThingManager *thingManager, LogEngine *logEngine, QObject *parent) : QObject(parent),
    m_thingManager(thingManager)
{
    qmlRegisterType<ScriptEvent>("nymea", 1, 0, "ThingEvent");
    qmlRegisterType<ScriptAction>("nymea", 1, 0, "ThingAction");
    qmlRegisterType<ScriptState>("nymea", 1, 0, "ThingState");
    qmlRegisterType<ScriptInterfaceAction>("nymea", 1, 0, "InterfaceAction");
    qmlRegisterType<ScriptInterfaceState>("nymea", 1, 0, "InterfaceState");
    qmlRegisterType<ScriptInterfaceEvent>("nymea", 1, 0, "InterfaceEvent");
    qmlRegisterType<ScriptAlarm>("nymea", 1, 0, "Alarm");
    qmlRegisterType<ScriptThing>("nymea", 1, 0, "Thing");
    qmlRegisterType<ScriptThings>("nymea", 1, 0, "Things");
    qmlRegisterUncreatableMetaObject(Action::staticMetaObject, "nymea", 1, 0, "Action", "Cannot create Actions. Use ThingAction instead.");


    m_logger = logEngine->registerLogSource("scripts", {"id", "event"});

    m_engine = new QQmlEngine(this);
    m_engine->setProperty("thingManager", reinterpret_cast<quint64>(m_thingManager));

    // Don't automatically print script warnings (that is, runtime errors, *not* console.warn() messages)
    // to stdout as they'd end up on the "default" logging category.
    // We collect them ourselves through the warnings() signal and print them to the dcScriptEngine category.
    m_engine->setOutputWarningsToStandardError(false);
    connect(m_engine, &QQmlEngine::warnings, this, [this](const QList<QQmlError> &warnings){
        foreach (const QQmlError &warning, warnings) {
            QMessageLogContext ctx(warning.url().toString().toUtf8(), warning.line(), "", "ScriptEngine");
            // Send to script logs
            onScriptMessage(
#if QT_VERSION >= QT_VERSION_CHECK(5,9,0)
                        warning.messageType(),
#else
                        QtMsgType::QtWarningMsg,
#endif
                        ctx, warning.description());
            // and to logging system
            qCWarning(dcScriptEngine()) << warning.toString();
        }
    });

    // console.log()/warn() messages instead are printed to the "qml" category. We install our own
    // filter to *always* get them, regardless of the configured logging categories
    if (!s_oldCategoryFilter) {
        s_oldCategoryFilter = QLoggingCategory::installFilter(&logCategoryFilter);
    }
    // and our own handler to redirect them to the ScriptEngine category
    if (s_engines.isEmpty()) {
        s_upstreamMessageHandler = qInstallMessageHandler(&logMessageHandler);
    }
    s_engines.append(this);


    QDir dir;
    if (!dir.exists(NymeaSettings::scriptsPath())) {
        dir.mkpath(NymeaSettings::scriptsPath());
    }

    loadScripts();

}

ScriptEngine::~ScriptEngine()
{
    foreach (Script *script, m_scripts) {
        unloadScript(script);
        delete script;
    }
    s_engines.removeAll(this);
    if (s_engines.isEmpty()) {
        qInstallMessageHandler(s_upstreamMessageHandler);
    }
}

Scripts ScriptEngine::scripts()
{
    Scripts ret;
    foreach (Script *script, m_scripts) {
        ret.append(*script);
    }
    return ret;
}

ScriptEngine::GetScriptReply ScriptEngine::scriptContent(const QUuid &id)
{
    GetScriptReply reply;
    if (!m_scripts.contains(id)) {
        reply.scriptError = ScriptErrorScriptNotFound;
        return reply;
    }
    QFile scriptFile(baseName(id) + ".qml");
    if (!scriptFile.open(QFile::ReadOnly)) {
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }
    reply.content = scriptFile.readAll();
    reply.scriptError = ScriptErrorNoError;

    scriptFile.close();
    return reply;
}

ScriptEngine::AddScriptReply ScriptEngine::addScript(const QString &name, const QByteArray &content)
{
    QUuid id = QUuid::createUuid();
    QString fileName = baseName(id) + ".qml";
    QString jsonFileName = baseName(id) + ".json";

    AddScriptReply reply;

    QFile jsonFile(jsonFileName);
    if (!jsonFile.open(QFile::ReadWrite)) {
        qCWarning(dcScriptEngine()) << "Error opening script metadata" << jsonFileName;
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }
    QVariantMap metadata;
    metadata.insert("name", name);
    jsonFile.write(QJsonDocument::fromVariant(metadata).toJson());
    jsonFile.close();

    QFile scriptFile(fileName);
    if (!scriptFile.open(QFile::WriteOnly)) {
        qCWarning(dcScriptEngine()) << "Error opening script file:" << fileName;
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }

    qint64 len = scriptFile.write(content);
    if (len != content.length()) {
        qCWarning(dcScriptEngine()) << "Error writing script content";
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }
    scriptFile.close();

    Script *script = new Script();
    script->setId(id);
    script->setName(name);
    bool loaded = loadScript(script);
    if (!loaded) {
        reply.scriptError = ScriptErrorInvalidScript;
        reply.errors = script->errors;
        delete script;
        QFile::remove(jsonFileName);
        QFile::remove(fileName);
        return reply;
    }

    m_scripts.insert(script->id(), script);

    reply.scriptError = ScriptErrorNoError;
    reply.script = *m_scripts.value(id);

    emit scriptAdded(reply.script);

    return reply;
}

ScriptEngine::ScriptError ScriptEngine::renameScript(const QUuid &id, const QString &name)
{
    if (!m_scripts.contains(id)) {
        qCWarning(dcScriptEngine()) << "No script with id" << id;
        return ScriptErrorScriptNotFound;
    }

    QString jsonFileName = baseName(id) + ".json";
    QFile jsonFile(jsonFileName);
    if (!jsonFile.open(QFile::ReadWrite)) {
        qCWarning(dcJsonRpc()) << "Erorr opening script json file" << jsonFileName;
        return ScriptErrorHardwareFailure;
    }
    QJsonParseError error;
    QJsonDocument jsonDocument = QJsonDocument::fromJson(jsonFile.readAll(), &error);
    QVariantMap jsonData = jsonDocument.toVariant().toMap();
    if (error.error != QJsonParseError::NoError) {
        qCWarning(dcScriptEngine()) << "Error parsing json file. Recreating it...";
        // This is non-critical as we could open it. We can recreate it now.
    }
    jsonData["name"] = name;
    QByteArray jsonString = QJsonDocument::fromVariant(jsonData).toJson();
    if (!jsonFile.resize(0) || jsonFile.write(jsonString) != jsonString.length()) {
        qCWarning(dcScriptEngine()) << "Error writing json metadata" << jsonFileName;
        return ScriptErrorHardwareFailure;
    }
    jsonFile.close();
    m_scripts[id]->setName(name);
    qCDebug(dcScriptEngine()) << "Script" << id << "renamed to" << name;
    emit scriptRenamed(*m_scripts.value(id));
    return ScriptErrorNoError;
}

ScriptEngine::EditScriptReply ScriptEngine::editScript(const QUuid &id, const QByteArray &content)
{
    EditScriptReply reply;

    if (!m_scripts.contains(id)) {
        qCWarning(dcScriptEngine()) << "No script with id" << id;
        reply.scriptError = ScriptErrorScriptNotFound;
        return reply;
    }

    Script *script = m_scripts.value(id);
    unloadScript(script);

    // Deleted compiled qml file to make sure we're reloading the new one
    QString compiledScriptFileName = baseName(id) + ".qmlc";
    QFile::remove(compiledScriptFileName);

    QString scriptFileName = baseName(id) + ".qml";
    QFile scriptFile(scriptFileName);
    if (!scriptFile.open(QFile::ReadWrite)) {
        qCWarning(dcScriptEngine()) << "Error opening script" << id;
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }

    QByteArray oldContent = scriptFile.readAll();
    scriptFile.close();

    if (!scriptFile.open(QFile::WriteOnly | QFile::Truncate)) {
        qCWarning(dcScriptEngine()) << "Error opening script for writing" << id;
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }
    qint64 bytesWritten = scriptFile.write(content);
    scriptFile.flush();
    scriptFile.close();
    if (bytesWritten != content.length()) {
        qCWarning(dcScriptEngine()) << "Error writing script content";
        reply.scriptError = ScriptErrorHardwareFailure;
        return reply;
    }

    bool loaded = loadScript(script);
    if (!loaded) {
        qCDebug(dcScriptEngine()) << "Restoring old content";
        reply.scriptError = ScriptErrorInvalidScript;
        reply.errors = script->errors;

        // Restore old content
        if (!scriptFile.open(QFile::WriteOnly | QFile::Truncate)) {
            qCWarning(dcScriptEngine()) << "Error restoring script content for" << id;
            return reply;
        }
        scriptFile.write(oldContent);
        scriptFile.flush();
        scriptFile.close();
        loadScript(script);

        return reply;
    }

    qCDebug(dcScriptEngine()) << "Script updated" << script->name();
    reply.scriptError = ScriptErrorNoError;
    emit scriptChanged(*script);
    return reply;
}

ScriptEngine::ScriptError ScriptEngine::removeScript(const QUuid &id)
{
    Script *script = m_scripts.take(id);
    if (!script) {
        return ScriptErrorScriptNotFound;
    }

    unloadScript(script);

    QString jsonFileName = baseName(id) + ".json";
    QString scriptFileName = baseName(id) + ".qml";
    QString compiledScriptFileName = baseName(id) + ".qmlc";

    QFile::remove(scriptFileName);
    QFile::remove(jsonFileName);
    QFile::remove(compiledScriptFileName);

    emit scriptRemoved(script->id());

    delete script;
    return ScriptErrorNoError;
}

void ScriptEngine::loadScripts()
{
    QDir dir(NymeaSettings::scriptsPath());
    foreach (const QString &entry, dir.entryList({"*.json"})) {
        qCDebug(dcScriptEngine()) << "Have script:" << entry;
        QFileInfo jsonFileInfo(NymeaSettings::scriptsPath() + entry);
        QString jsonFileName = jsonFileInfo.absoluteFilePath();
        QString scriptFileName = jsonFileInfo.absolutePath() + "/" + jsonFileInfo.baseName() +  ".qml";
        if (!QFile::exists(scriptFileName)) {
            qCWarning(dcScriptEngine()) << "Missing script" << scriptFileName;
            continue;
        }

        QFile jsonFile(jsonFileName);
        if (!jsonFile.open(QFile::ReadOnly)) {
            qCWarning(dcScriptEngine()) << "Failed to open script metadata" << jsonFileName;
            continue;
        }

        QJsonParseError error;
        QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonFile.readAll(), &error);
        jsonFile.close();
        if (error.error != QJsonParseError::NoError) {
            qCWarning(dcScriptEngine()) << "Error parsing script metadata" << jsonFileName;
            continue;
        }

        Script *script = new Script();
        script->setId(QUuid(jsonFileInfo.baseName()));
        script->setName(jsonDoc.toVariant().toMap().value("name").toString());

        bool loaded = loadScript(script);
        if (!loaded) {
            qCWarning(dcScriptEngine()) << "Script failed to load:";
            delete script;
            continue;
        }

        m_scripts.insert(script->id(), script);
        qCDebug(dcScriptEngine()) << "Script loaded" << scriptFileName;
    }
}

bool ScriptEngine::loadScript(Script *script)
{
    qCDebug(dcScriptEngine()) << "Loading script" << script->name();

    QString fileName = baseName(script->id()) + ".qml";
    QString jsonFileName = baseName(script->id()) + ".json";

    QFile jsonFile(jsonFileName);
    if (!jsonFile.open(QFile::ReadOnly)) {
        qCWarning(dcScriptEngine()) << "Failed to open script metadata";
        return false;
    }
    QJsonParseError error;
    QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonFile.readAll(), &error);
    jsonFile.close();

    if (error.error != QJsonParseError::NoError) {
        qCWarning(dcScriptEngine()) << "Failed to parse script metadata";
        return false;
    }

    QString name = jsonDoc.toVariant().toMap().value("name").toString();

    script->errors.clear();


    script->component = new QQmlComponent(m_engine, QUrl::fromLocalFile(fileName), this);
    script->context = new QQmlContext(m_engine, this);
    script->context->setContextProperty("logger", QVariant::fromValue(m_logger));
    script->context->setContextProperty("scriptId", script->id().toString());
    script->context->setContextProperty("scriptName", name);
    script->object = script->component->create(script->context);

    if (!script->object) {
        qCWarning(dcScriptEngine()) << "Script failed to load:";
        foreach (const QQmlError &error, script->component->errors()) {
            qCWarning(dcScriptEngine()) << error.toString();
            script->errors.append(QString("%1:%2: %3").arg(error.line()).arg(error.column()).arg(error.description()));
        }
        delete script->context;
        delete script->component;

        m_engine->clearComponentCache();
        return false;
    }
    return true;
}

void ScriptEngine::unloadScript(Script *script)
{
    if (!script->object || !script->component || !script->context) {
        qCWarning(dcScriptEngine()) << "Script seems not to be loaded. Cannot unload.";
        return;
    }
    delete script->object;
    script->object = nullptr;
    delete script->component;
    script->component = nullptr;
    delete script->context;
    script->context = nullptr;

    m_engine->clearComponentCache();
    qCDebug(dcScriptEngine()) << "Unloading script" << script->name();
}

QString ScriptEngine::baseName(const QUuid &id)
{
    QString path = NymeaSettings::storagePath() + "/scripts/";
    QString basename = id.toString().remove(QRegularExpression("[{}]"));
    return path + basename;
}

void ScriptEngine::onScriptMessage(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
    QFileInfo fi(context.file ? QString::fromUtf8(context.file) : QString());
    QUuid scriptId(fi.baseName());
    if (!m_scripts.contains(scriptId)) {
        return;
    }
    emit scriptConsoleMessage(scriptId, type == QtDebugMsg ? ScriptMessageTypeLog : ScriptMessageTypeWarning, QString::number(context.line) + ": " + message);
}

void ScriptEngine::logMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
    if (qstrcmp(context.category, "qml") != 0 && qstrcmp(context.category, "ScriptRuntime") != 0) {
        s_upstreamMessageHandler(type, context, message);
        return;
    }

    QMutexLocker locker(&s_loggerMutex);
    // Copy the message to the script engine
    foreach (ScriptEngine *engine, s_engines) {
        engine->onScriptMessage(type, context, message);
    }

    if (!s_oldCategoryFilter) {
        return;
    }

    // Redirect qml messages to the ScriptEngine handler
    QMessageLogContext newContext(context.file, context.line, context.function, "ScriptEngine");
    QLoggingCategory *category = new QLoggingCategory("ScriptEngine", type);
    s_oldCategoryFilter(category);
    if (category->isEnabled(type)) {
        QFileInfo fi(context.file ? QString::fromUtf8(context.file) : QString());
        s_upstreamMessageHandler(type, newContext, fi.fileName() + ":" + QString::number(context.line) + ": " + message);
    }
    delete category;
}

void ScriptEngine::logCategoryFilter(QLoggingCategory *category)
{
    // always enable qml logs, regardless what the filters are
    if (qstrcmp(category->categoryName(), "qml") == 0) {
        category->setEnabled(QtDebugMsg, true);
        category->setEnabled(QtWarningMsg, true);
    } else if (s_oldCategoryFilter) {
        s_oldCategoryFilter(category);
    }
}

}
}
