// SPDX-FileCopyrightText: 2023 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT

#ifdef CHATTERINO_HAVE_PLUGINS
#    include "controllers/plugins/PluginController.hpp"

#    include "Application.hpp"
#    include "common/Args.hpp"
#    include "common/network/NetworkCommon.hpp"
#    include "common/QLogging.hpp"
#    include "controllers/commands/CommandContext.hpp"
#    include "controllers/commands/CommandController.hpp"
#    include "controllers/plugins/api/Accounts.hpp"
#    include "controllers/plugins/api/ChannelRef.hpp"
#    include "controllers/plugins/api/ConnectionHandle.hpp"
#    include "controllers/plugins/api/DebugLibrary.hpp"
#    include "controllers/plugins/api/HTTPRequest.hpp"
#    include "controllers/plugins/api/HTTPResponse.hpp"
#    include "controllers/plugins/api/Images.hpp"
#    include "controllers/plugins/api/IOWrapper.hpp"
#    include "controllers/plugins/api/JSON.hpp"
#    include "controllers/plugins/api/Message.hpp"
#    include "controllers/plugins/api/WebSocket.hpp"
#    include "controllers/plugins/api/WindowManager.hpp"
#    include "controllers/plugins/LuaAPI.hpp"
#    include "controllers/plugins/LuaUtilities.hpp"
#    include "controllers/plugins/SolTypes.hpp"
#    include "messages/MessageBuilder.hpp"
#    include "messages/MessageElement.hpp"
#    include "singletons/Paths.hpp"
#    include "singletons/Settings.hpp"
#    include "singletons/WindowManager.hpp"
#    include "widgets/splits/SplitContainer.hpp"
#    include "widgets/Window.hpp"

#    include <lauxlib.h>
#    include <lua.h>
#    include <lualib.h>
#    include <QJsonDocument>
#    include <sol/overload.hpp>
#    include <sol/sol.hpp>
#    include <sol/types.hpp>
#    include <sol/variadic_args.hpp>
#    include <sol/variadic_results.hpp>

#    include <memory>
#    include <utility>
#    include <variant>

namespace chatterino {

PluginController::PluginController(const Paths &paths_)
    : paths(paths_)
{
    this->loaders_.emplace_back("chatterino.json", &lua::api::loadJson);
}

void PluginController::initialize(Settings &settings)
{
    // actuallyInitialize will be called by this connection
    settings.pluginsEnabled.connect([this](bool enabled) {
        if (enabled)
        {
            this->loadPlugins();
        }
        else
        {
            // uninitialize plugins
            this->plugins_.clear();
        }
    });
}

void PluginController::loadPlugins()
{
    this->plugins_.clear();
    auto dir = QDir(this->paths.pluginsDirectory);
    qCDebug(chatterinoLua) << "Loading plugins in" << dir.path();
    for (const auto &info :
         dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot))
    {
        auto pluginDir = QDir(info.absoluteFilePath());
        this->tryLoadFromDir(pluginDir);
    }
}

bool PluginController::tryLoadFromDir(const QDir &pluginDir)
{
    // look for init.lua
    auto index = QFileInfo(pluginDir.filePath("init.lua"));
    qCDebug(chatterinoLua) << "Looking for init.lua and info.json in"
                           << pluginDir.path();
    if (!index.exists())
    {
        qCWarning(chatterinoLua)
            << "Missing init.lua in plugin directory:" << pluginDir.path();
        return false;
    }
    qCDebug(chatterinoLua) << "Found init.lua, now looking for info.json!";
    auto infojson = QFileInfo(pluginDir.filePath("info.json"));
    if (!infojson.exists())
    {
        qCWarning(chatterinoLua)
            << "Missing info.json in plugin directory" << pluginDir.path();
        return false;
    }
    QFile infoFile(infojson.absoluteFilePath());
    if (!infoFile.open(QIODevice::ReadOnly))
    {
        qCWarning(chatterinoLua)
            << "Could not open info.json" << infoFile.errorString();
        return false;
    }
    auto everything = infoFile.readAll();
    auto doc = QJsonDocument::fromJson(everything);
    if (!doc.isObject())
    {
        qCWarning(chatterinoLua)
            << "info.json root is not an object" << pluginDir.path();
        return false;
    }

    auto meta = PluginMeta(doc.object());
    if (!meta.isValid())
    {
        qCWarning(chatterinoLua)
            << "Plugin from" << pluginDir << "is invalid because:";
        for (const auto &why : meta.errors)
        {
            qCWarning(chatterinoLua) << "- " << why;
        }
        auto plugin = std::make_unique<Plugin>(pluginDir.dirName(), nullptr,
                                               meta, pluginDir);
        this->plugins_.insert({pluginDir.dirName(), std::move(plugin)});
        return false;
    }
    this->load(index, pluginDir, meta);
    return true;
}

void PluginController::openLibrariesFor(Plugin *plugin)
{
    auto *L = plugin->state_;
    lua::StackGuard guard(L);
    sol::state_view lua(L);
    // Stuff to change, remove or hide behind a permission system:
    static const std::vector<luaL_Reg> loadedlibs = {
        luaL_Reg{LUA_GNAME, luaopen_base},
        // - load - don't allow in release mode

        luaL_Reg{LUA_COLIBNAME, luaopen_coroutine},
        luaL_Reg{LUA_TABLIBNAME, luaopen_table},
        // luaL_Reg{LUA_OSLIBNAME, luaopen_os},
        // - fs access
        // - environ access
        // - exit
        luaL_Reg{LUA_STRLIBNAME, luaopen_string},
        luaL_Reg{LUA_MATHLIBNAME, luaopen_math},
        luaL_Reg{LUA_UTF8LIBNAME, luaopen_utf8},
        luaL_Reg{LUA_LOADLIBNAME, luaopen_package},
    };
    // Warning: Do not add debug library to this, it would make the security of
    // this a living nightmare due to stuff like registry access
    // - Mm2PL

    for (const auto &reg : loadedlibs)
    {
        luaL_requiref(L, reg.name, reg.func, int(true));
        lua_pop(L, 1);
    }
    luaL_requiref(L, LUA_IOLIBNAME, luaopen_io, int(false));
    lua_setfield(L, LUA_REGISTRYINDEX, lua::api::REG_REAL_IO_NAME);

    auto r = lua.registry();
    auto g = lua.globals();
    auto c2 = lua.create_table();
    g["c2"] = c2;

    // ban functions
    // Note: this might not be fully secure? some kind of metatable fuckery might come up?

#    ifndef NDEBUG
    lua.registry()["real_load"] = lua.globals()["load"];
#    endif
    // See chatterino::lua::api::g_load implementation

    g["loadfile"] = sol::nil;
    g["dofile"] = sol::nil;

    // set up package lib
    {
        auto package = g["package"];
        package["cpath"] = "";
        package["path"] = "";

        sol::protected_function tbremove = g["table"]["remove"];

        // remove searcher_Croot, searcher_C and searcher_Lua leaving only searcher_preload
        sol::table searchers = package["searchers"];
        for (int i = 0; i < 3; i++)
        {
            tbremove(searchers);
        }
        searchers.add(&lua::api::searcherRelative);
        searchers.add(&lua::api::searcherAbsolute);
    }
    // set up io lib
    {
        auto c2io = lua.create_table();
        auto realio = r[lua::api::REG_REAL_IO_NAME];
        c2io["type"] = realio["type"];
        g["io"] = c2io;
        // prevent plugins getting direct access to realio
        r[LUA_LOADED_TABLE]["io"] = c2io;

        // Don't give plugins the option to shit into our stdio
        r["_IO_input"] = sol::nil;
        r["_IO_output"] = sol::nil;
    }
    // set up debug lib
    {
        auto debuglib = lua.create_table();
        g["debug"] = debuglib;

        debuglib.set_function("traceback", lua::api::debugTraceback);
    }
    PluginController::initSol(lua, plugin);
}

// TODO: investigate if `plugin` can ever point to an invalid plugin,
// especially in cases when the plugin is errored.
void PluginController::initSol(sol::state_view &lua, Plugin *plugin)
{
    auto g = lua.globals();
    // Do not capture plugin->state_ in lambdas, this makes the functions unusable in callbacks
    g.set_function("print", &lua::api::g_print);
    g.set_function("load", &lua::api::g_load);

    sol::table c2 = g["c2"];
    c2.set_function("register_command",
                    [plugin](const QString &name, sol::protected_function cb) {
                        return plugin->registerCommand(name, std::move(cb));
                    });
    c2.set_function("register_callback", &lua::api::c2_register_callback);
    c2.set_function("log", &lua::api::c2_log);
    c2.set_function("later", &lua::api::c2_later);

    lua::api::ChannelRef::createUserType(c2);
    lua::api::HTTPResponse::createUserType(c2);
    lua::api::HTTPRequest::createUserType(c2);
    lua::api::WebSocket::createUserType(c2, plugin);
    lua::api::ConnectionHandle::createUserType(c2);
    lua::api::message::createUserType(c2);
    lua::api::images::createUserTypes(c2);
    lua::api::createAccounts(c2);
    lua::api::windowmanager::createUserTypes(c2);
    c2["ChannelType"] = lua::createEnumTable<Channel::Type>(lua);
    c2["HTTPMethod"] = lua::createEnumTable<NetworkRequestType>(lua);
    c2["EventType"] = lua::createEnumTable<lua::api::EventType>(lua);
    c2["LogLevel"] = lua::createEnumTable<lua::api::LogLevel>(lua);
    c2["MessageFlag"] =
        lua::createEnumTable<MessageFlag, MessageFlag::None>(lua);
    c2["MessageElementFlag"] = lua::createEnumTable<MessageElementFlag>(lua);
    c2["FontStyle"] = lua::createEnumTable<FontStyle>(lua);
    c2["MessageContext"] = lua::createEnumTable<MessageContext>(lua);
    c2["LinkType"] =
        lua::createEnumTable<lua::api::message::ExposedLinkType>(lua);
    c2["SplitContainerNodeType"] =
        lua::createEnumTable<SplitContainer::Node::Type>(lua);
    c2["WindowType"] = lua::createEnumTable<WindowType>(lua);

    c2["windows"] = getApp()->getWindows();

    sol::table io = g["io"];
    io.set_function(
        "open", sol::overload(&lua::api::io_open, &lua::api::io_open_modeless));
    io.set_function("lines", sol::overload(&lua::api::io_lines,
                                           &lua::api::io_lines_noargs));
    io.set_function("input", sol::overload(&lua::api::io_input_argless,
                                           &lua::api::io_input_name,
                                           &lua::api::io_input_file));
    io.set_function("output", sol::overload(&lua::api::io_output_argless,
                                            &lua::api::io_output_name,
                                            &lua::api::io_output_file));
    io.set_function("close", sol::overload(&lua::api::io_close_argless,
                                           &lua::api::io_close_file));
    io.set_function("flush", sol::overload(&lua::api::io_flush_argless,
                                           &lua::api::io_flush_file));
    io.set_function("read", &lua::api::io_read);
    io.set_function("write", &lua::api::io_write);
    io.set_function("popen", &lua::api::io_popen);
    io.set_function("tmpfile", &lua::api::io_tmpfile);

    sol::table package = g["package"];
    package.set_function("loadlib", &lua::api::package_loadlib);

    for (const auto &[name, fn] : this->loaders_)
    {
        package["preload"][name] = [fn](sol::this_main_state state) {
            sol::state_view sv(state);
            return fn(sv);
        };
    }
}

void PluginController::load(const QFileInfo &index, const QDir &pluginDir,
                            const PluginMeta &meta)
{
    auto pluginName = pluginDir.dirName();
    lua_State *l = luaL_newstate();
    auto plugin = std::make_unique<Plugin>(pluginName, l, meta, pluginDir);
    auto *temp = plugin.get();
    this->plugins_.insert({pluginName, std::move(plugin)});

    if (getApp()->getArgs().safeMode)
    {
        // This isn't done earlier to ensure the user can disable a misbehaving plugin
        qCWarning(chatterinoLua) << "Skipping loading plugin " << meta.name
                                 << " because safe mode is enabled.";
        return;
    }
    this->openLibrariesFor(temp);

    if (!PluginController::isPluginEnabled(pluginName) ||
        !getSettings()->pluginsEnabled)
    {
        qCDebug(chatterinoLua) << "Skipping loading" << pluginName << "("
                               << meta.name << ") because it is disabled";
        return;
    }
    temp->dataDirectory().mkpath(".");

    // make sure we capture log messages during load
    this->onPluginLoaded.invoke(temp);
    qCDebug(chatterinoLua) << "Running lua file:" << index;
    int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str());
    if (err != 0)
    {
        temp->error_ = lua::humanErrorText(l, err);
        qCWarning(chatterinoLua)
            << "Failed to load" << pluginName << "plugin from" << index << ": "
            << temp->error_;
        return;
    }
    qCInfo(chatterinoLua) << "Loaded" << pluginName << "plugin from" << index;
}

bool PluginController::reload(const QString &id)
{
    auto it = this->plugins_.find(id);
    if (it == this->plugins_.end())
    {
        return false;
    }

    for (const auto &[cmd, _] : it->second->ownedCommands)
    {
        getApp()->getCommands()->unregisterPluginCommand(cmd);
    }
    QDir loadDir = it->second->loadDirectory_;
    // Since Plugin owns the state, it will clean up everything related to it
    this->plugins_.erase(id);
    this->tryLoadFromDir(loadDir);
    return true;
}

QString PluginController::tryExecPluginCommand(const QString &commandName,
                                               const CommandContext &ctx)
{
    for (auto &[name, plugin] : this->plugins_)
    {
        if (auto it = plugin->ownedCommands.find(commandName);
            it != plugin->ownedCommands.end())
        {
            sol::state_view lua(plugin->state_);
            sol::table args = lua.create_table_with(
                "words", ctx.words,                           //
                "channel", lua::api::ChannelRef(ctx.channel)  //
            );

            auto result =
                lua::tryCall<std::optional<QString>>(it->second, args);
            if (!result)
            {
                ctx.channel->addSystemMessage(
                    QStringView(
                        u"Failed to evaluate command from plugin %1: %2")
                        .arg(plugin->meta.name, result.error()));
                return {};
            }

            auto opt = result.value();
            if (!opt)
            {
                return {};
            }
            return *opt;
        }
    }
    qCCritical(chatterinoLua)
        << "Something's seriously up, no plugin owns command" << commandName
        << "yet a call to execute it came in";
    assert(false && "missing plugin command owner");
    return {};
}

bool PluginController::isPluginEnabled(const QString &id)
{
    auto vec = getSettings()->enabledPlugins.getValue();
    auto it = std::find(vec.begin(), vec.end(), id);
    return it != vec.end();
}

Plugin *PluginController::getPluginByStatePtr(lua_State *L)
{
    lua_geti(L, LUA_REGISTRYINDEX, LUA_RIDX_MAINTHREAD);
    // Use the main thread for identification, not a coroutine instance
    auto *mainL = lua_tothread(L, -1);
    lua_pop(L, 1);
    L = mainL;
    for (auto &[name, plugin] : this->plugins_)
    {
        if (plugin->state_ == L)
        {
            return plugin.get();
        }
    }
    return nullptr;
}

const std::map<QString, std::unique_ptr<Plugin>> &PluginController::plugins()
    const
{
    return this->plugins_;
}

std::pair<bool, QStringList> PluginController::updateCustomCompletions(
    const QString &query, const QString &fullTextContent, int cursorPosition,
    bool isFirstWord) const
{
    QStringList results;

    for (const auto &[name, pl] : this->plugins())
    {
        if (!pl->error().isNull() || pl->state_ == nullptr)
        {
            continue;
        }

        auto opt = pl->getCompletionCallback();
        if (opt)
        {
            qCDebug(chatterinoLua)
                << "Processing custom completions from plugin" << name;
            auto &cb = *opt;
            sol::state_view view(pl->state_);
            auto errOrList = lua::tryCall<sol::table>(
                cb,
                toTable(pl->state_, lua::api::CompletionEvent{
                                        .query = query,
                                        .full_text_content = fullTextContent,
                                        .cursor_position = cursorPosition,
                                        .is_first_word = isFirstWord,
                                    }));
            if (!errOrList.has_value())
            {
                qCDebug(chatterinoLua)
                    << "Got error from plugin " << pl->meta.name
                    << " while refreshing tab completion: "
                    << errOrList.error();
                continue;
            }

            auto list = lua::api::CompletionList(*errOrList);
            if (list.hideOthers)
            {
                results = QStringList(list.values.begin(), list.values.end());
                return {true, results};
            }
            results += QStringList(list.values.begin(), list.values.end());
        }
    }

    return {false, results};
}

WebSocketPool &PluginController::webSocketPool()
{
    return this->webSocketPool_;
}

}  // namespace chatterino
#endif
