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

#include "mocks/Helix.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
#    include "Application.hpp"
#    include "common/Channel.hpp"
#    include "common/network/NetworkCommon.hpp"
#    include "controllers/accounts/AccountController.hpp"
#    include "controllers/commands/Command.hpp"  // IWYU pragma: keep
#    include "controllers/commands/CommandController.hpp"
#    include "controllers/plugins/api/ChannelRef.hpp"
#    include "controllers/plugins/api/WebSocket.hpp"
#    include "controllers/plugins/Plugin.hpp"
#    include "controllers/plugins/PluginController.hpp"
#    include "controllers/plugins/PluginPermission.hpp"
#    include "controllers/plugins/SolTypes.hpp"  // IWYU pragma: keep
#    include "lib/Snapshot.hpp"
#    include "messages/Message.hpp"
#    include "messages/MessageElement.hpp"
#    include "mocks/BaseApplication.hpp"
#    include "mocks/Channel.hpp"
#    include "mocks/EmoteController.hpp"
#    include "mocks/Logging.hpp"
#    include "mocks/TwitchIrcServer.hpp"
#    include "NetworkHelpers.hpp"
#    include "singletons/Logging.hpp"
#    include "singletons/WindowManager.hpp"
#    include "Test.hpp"

#    include <lauxlib.h>
#    include <sol/state_view.hpp>
#    include <sol/table.hpp>

#    include <memory>
#    include <optional>
#    include <utility>

using namespace chatterino;
using chatterino::mock::MockChannel;
using namespace std::chrono_literals;

namespace {

constexpr bool UPDATE_SNAPSHOTS = false;

const QString TEST_SETTINGS = R"(
{
    "plugins": {
        "supportEnabled": true,
        "enabledPlugins": [
            "test"
        ]
    },
    "accounts": {
        "uid117166826": {
            "username": "testaccount_420",
            "userID": "117166826",
            "clientID": "abc",
            "oauthToken": "def"
        },
        "current": "testaccount_420"
    }
}
)";

class MockTwitch : public mock::MockTwitchIrcServer
{
public:
    ChannelPtr mm2pl = std::make_shared<MockChannel>("mm2pl");

    ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) override
    {
        if (dirtyChannelName == "mm2pl")
        {
            return this->mm2pl;
        }
        return Channel::getEmpty();
    }

    std::shared_ptr<Channel> getChannelOrEmptyByID(
        const QString &channelID) override
    {
        if (channelID == "117691339")
        {
            return this->mm2pl;
        }
        return Channel::getEmpty();
    }
};

class MockApplication : public mock::BaseApplication
{
public:
    MockApplication()
        : mock::BaseApplication(TEST_SETTINGS)
        , plugins(this->paths_)
        , commands(this->paths_)
        , windows(this->args_, this->paths_, this->settings, this->theme,
                  this->fonts)
    {
    }

    PluginController *getPlugins() override
    {
        return &this->plugins;
    }

    CommandController *getCommands() override
    {
        return &this->commands;
    }

    EmoteController *getEmotes() override
    {
        return &this->emotes;
    }

    mock::MockTwitchIrcServer *getTwitch() override
    {
        return &this->twitch;
    }

    ILogging *getChatLogger() override
    {
        return &this->logging;
    }

    AccountController *getAccounts() override
    {
        return &this->accounts;
    }

    WindowManager *getWindows() override
    {
        return &this->windows;
    }

    PluginController plugins;
    mock::Logging logging;
    CommandController commands;
    mock::EmoteController emotes;
    MockTwitch twitch;
    AccountController accounts;
    mock::Helix helix;
    WindowManager windows;
};

QDir luaTestBaseDir(const QString &category)
{
    QDir snapshotDir(QStringLiteral(__FILE__));
    snapshotDir.cd("../../lua/");
    snapshotDir.cd(category);
    return snapshotDir;
}

QStringList discoverLuaTests(const QString &category)
{
    auto files =
        luaTestBaseDir(category).entryList(QDir::NoDotAndDotDot | QDir::Files);
    for (auto &file : files)
    {
        file.remove(".lua");
    }
    return files;
}

std::string luaTestPath(const QString &category, const QString &entry)
{
    return luaTestBaseDir(category).filePath(entry + ".lua").toStdString();
}

bool runLuaTest(const QString &category, const QString &entry,
                sol::state_view lua)
{
    auto loadResult = lua.load_file(luaTestPath(category, entry));
    auto pfn = loadResult.get<sol::protected_function>();
    pfn.set_error_handler(lua["debug"]["traceback"]);
    auto pfr = pfn.call();
    EXPECT_TRUE(pfr.valid());
    if (!pfr.valid())
    {
        qDebug().noquote() << "Test" << entry << "failed:";
        sol::error err = pfr;
        qDebug().noquote() << err.what();
        return false;
    }
    return true;
}

}  // namespace

namespace chatterino {

class PluginControllerAccess
{
public:
    static bool tryLoadFromDir(const QDir &pluginDir)
    {
        return getApp()->getPlugins()->tryLoadFromDir(pluginDir);
    }

    static void openLibrariesFor(Plugin *plugin)
    {
        getApp()->getPlugins()->openLibrariesFor(plugin);
    }

    static std::map<QString, std::unique_ptr<Plugin>> &plugins()
    {
        return getApp()->getPlugins()->plugins_;
    }

    static lua_State *state(Plugin *pl)
    {
        return pl->state_;
    }
};

}  // namespace chatterino

class PluginTest : public ::testing::Test
{
protected:
    void configure(std::vector<PluginPermission> permissions = {})
    {
        this->app = std::make_unique<MockApplication>();

        auto &plugins = PluginControllerAccess::plugins();
        {
            PluginMeta meta;
            meta.name = "Test";
            meta.license = "MIT";
            meta.homepage = "https://github.com/Chatterino/chatterino2";
            meta.description = "Plugin for tests";
            meta.permissions = std::move(permissions);

            QDir plugindir =
                QDir(app->paths_.pluginsDirectory).absoluteFilePath("test");

            plugindir.mkpath(".");
            auto temp = std::make_unique<Plugin>("test", luaL_newstate(), meta,
                                                 plugindir);
            this->rawpl = temp.get();
            plugins.insert({"test", std::move(temp)});
        }

        // XXX: this skips PluginController::load()
        PluginControllerAccess::openLibrariesFor(rawpl);
        this->lua = new sol::state_view(PluginControllerAccess::state(rawpl));

        this->channel = app->twitch.mm2pl;
        this->rawpl->dataDirectory().mkpath(".");
        initializeHelix(&this->app->helix);
        this->app->accounts.load();
    }

    void TearDown() override
    {
        // perform safe destruction of the plugin
        delete this->lua;
        this->lua = nullptr;
        PluginControllerAccess::plugins().clear();
        this->rawpl = nullptr;
        this->app.reset();
    }

    Plugin *rawpl = nullptr;
    std::unique_ptr<MockApplication> app;
    sol::state_view *lua;
    ChannelPtr channel;
};

TEST_F(PluginTest, testCommands)
{
    configure();

    lua->script(R"lua(
        _G.called = false
        _G.words = nil
        _G.channel = nil
        c2.register_command("/test", function(ctx)
            _G.called = true
            _G.words = ctx.words
            _G.channel = ctx.channel
        end)
    )lua");

    EXPECT_EQ(app->commands.pluginCommands(), QStringList{"/test"});
    app->commands.execCommand("/test with arguments", channel, false);
    bool called = (*lua)["called"];
    EXPECT_EQ(called, true);

    EXPECT_NE((*lua)["words"], sol::nil);
    {
        sol::table tbl = (*lua)["words"];
        std::vector<std::string> words;
        for (auto &o : tbl)
        {
            words.push_back(o.second.as<std::string>());
        }
        EXPECT_EQ(words,
                  std::vector<std::string>({"/test", "with", "arguments"}));
    }

    sol::object chnobj = (*lua)["channel"];
    EXPECT_EQ(chnobj.get_type(), sol::type::userdata);
    lua::api::ChannelRef ref = chnobj.as<lua::api::ChannelRef>();
    EXPECT_EQ(ref.get_name(), channel->getName());
}

TEST_F(PluginTest, testCompletion)
{
    configure();

    lua->script(R"lua(
        _G.called = false
        _G.query = nil
        _G.full_text_content = nil
        _G.cursor_position = nil
        _G.is_first_word = nil

        c2.register_callback(
            c2.EventType.CompletionRequested,
            function(ev)
                _G.called = true
                _G.query = ev.query
                _G.full_text_content = ev.full_text_content
                _G.cursor_position = ev.cursor_position
                _G.is_first_word = ev.is_first_word
                if ev.query == "exclusive" then
                    return {
                        hide_others = true,
                        values = {"Completion1", "Completion2"}
                    }
                end
                return {
                    hide_others = false,
                    values = {"Completion"},
                }
            end
        )
    )lua");

    bool done{};
    QStringList results;
    std::tie(done, results) =
        app->plugins.updateCustomCompletions("foo", "foo", 3, true);
    ASSERT_EQ(done, false);
    ASSERT_EQ(results, QStringList{"Completion"});

    ASSERT_EQ((*lua).get<std::string>("query"), "foo");
    ASSERT_EQ((*lua).get<std::string>("full_text_content"), "foo");
    ASSERT_EQ((*lua).get<int>("cursor_position"), 3);
    ASSERT_EQ((*lua).get<bool>("is_first_word"), true);

    std::tie(done, results) = app->plugins.updateCustomCompletions(
        "exclusive", "foo exclusive", 13, false);
    ASSERT_EQ(done, true);
    ASSERT_EQ(results, QStringList({"Completion1", "Completion2"}));

    ASSERT_EQ((*lua).get<std::string>("query"), "exclusive");
    ASSERT_EQ((*lua).get<std::string>("full_text_content"), "foo exclusive");
    ASSERT_EQ((*lua).get<int>("cursor_position"), 13);
    ASSERT_EQ((*lua).get<bool>("is_first_word"), false);
}

TEST_F(PluginTest, testChannel)
{
    configure();
    lua->script(R"lua(
        chn = c2.Channel.by_name("mm2pl")
    )lua");

    ASSERT_EQ(lua->script(R"lua( return chn:get_name() )lua").get<QString>(0),
              "mm2pl");
    ASSERT_EQ(
        lua->script(R"lua( return chn:get_type() )lua").get<Channel::Type>(0),
        Channel::Type::Twitch);
    ASSERT_EQ(
        lua->script(R"lua( return chn:get_display_name() )lua").get<QString>(0),
        "mm2pl");
    // TODO: send_message, add_system_message

    ASSERT_EQ(
        lua->script(R"lua( return chn:is_twitch_channel() )lua").get<bool>(0),
        true);

    ASSERT_TRUE(lua->script(R"lua(
        assert(c2.Channel.by_name("mm2pl") == c2.Channel.by_name("mm2pl"))
        assert(not c2.Channel.by_name("mm2pl") ~= c2.Channel.by_name("mm2pl"))
        assert(c2.Channel.by_name("mm2pl") ~= c2.Channel.by_name("twitchdev"))
        assert(c2.Channel.by_name("twitchdev") == c2.Channel.by_name("twitchdev"))
        assert(c2.Channel.by_name("twitchdev") == c2.Channel.by_name("other")) -- empty channel
    )lua")
                    .valid());

    // this is not a TwitchChannel
    const auto *shouldThrow1 = R"lua(
        return chn:is_broadcaster()
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow1));
    const auto *shouldThrow2 = R"lua(
        return chn:is_mod()
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow2));
    const auto *shouldThrow3 = R"lua(
        return chn:is_vip()
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow3));
    const auto *shouldThrow4 = R"lua(
        return chn:get_twitch_id()
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow4));
}

TEST_F(PluginTest, testHttp)
{
    {
        PluginPermission net;
        net.type = PluginPermission::Type::Network;
        configure({net});
    }

    lua->script(R"lua(
        function DoReq(url, postdata)
            r = c2.HTTPRequest.create(method, url)
            r:on_success(function(res)
                status = res:status()
                data = res:data()
                error = res:error()
                success = true
            end)
            r:on_error(function(res)
                status = res:status()
                data = res:data()
                error = res:error()
                failure = true
            end)
            r:finally(function()
                finally = true
                done()
            end)
            if postdata ~= "" then
                r:set_payload(postdata)
                r:set_header("Content-Type", "text/plain")
            end
            r:set_timeout(1000)
            r:execute()
        end
    )lua");

    struct RequestCase {
        QString url;
        bool success;
        bool failure;

        int status;
        QString error;

        NetworkRequestType meth = NetworkRequestType::Get;
        QByteArray data;  // null means do not check
    };

    std::vector<RequestCase> cases{
        {"/status/200", true, false, 200, "200"},
        {"/delay/2", false, true, 0, "TimeoutError"},
        {"/post", true, false, 200, "200", NetworkRequestType::Post,
         "Example data"},
    };

    for (const auto &c : cases)
    {
        lua->script(R"lua(
            success = false
            failure = false
            finally = false

            status = nil
            data = nil
            error = nil
        )lua");
        RequestWaiter waiter;
        (*lua)["method"] = c.meth;
        (*lua)["done"] = [&waiter]() {
            waiter.requestDone();
        };

        (*lua)["DoReq"](HTTPBIN_BASE_URL.toStdString().c_str() + c.url, c.data);
        waiter.waitForRequest();

        EXPECT_EQ(lua->get<bool>("success"), c.success);
        EXPECT_EQ(lua->get<bool>("failure"), c.failure);
        EXPECT_EQ(lua->get<bool>("finally"), true);

        if (c.status != 0)
        {
            EXPECT_EQ(lua->get<int>("status"), c.status);
        }
        else
        {
            EXPECT_EQ((*lua)["status"], sol::nil);
        }
        EXPECT_EQ(lua->get<QString>("error"), c.error);
        if (!c.data.isNull())
        {
            EXPECT_EQ(lua->get<QByteArray>("data"), c.data);
        }
    }
}

const QByteArray TEST_FILE_DATA = "Test file data\nWith a new line.\n";

TEST_F(PluginTest, ioTest)
{
    {
        PluginPermission ioread;
        PluginPermission iowrite;
        ioread.type = PluginPermission::Type::FilesystemRead;
        iowrite.type = PluginPermission::Type::FilesystemWrite;
        configure({ioread, iowrite});
    }

    lua->set("TEST_DATA", TEST_FILE_DATA);

    lua->script(R"lua(
        f, err = io.open("testfile", "w")
        print(f, err)
        f:write(TEST_DATA)
        f:close()

        f, err = io.open("testfile", "r")
        out = f:read("a")
        f:close()
    )lua");
    EXPECT_EQ(lua->get<QByteArray>("out"), TEST_FILE_DATA);

    lua->script(R"lua(
        io.input("testfile")
        out = io.read("a")
    )lua");
    EXPECT_EQ(lua->get<QByteArray>("out"), TEST_FILE_DATA);

    const auto *shouldThrow1 = R"lua(
        io.popen("/bin/sh", "-c", "notify-send \"This should not execute.\"")
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow1));
    const auto *shouldThrow2 = R"lua(
        io.tmpfile()
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow2));
}

TEST_F(PluginTest, ioNoPerms)
{
    configure();
    auto file = rawpl->dataDirectory().filePath("testfile");
    QFile f(file);
    EXPECT_TRUE(f.open(QFile::WriteOnly));
    f.write(TEST_FILE_DATA);
    f.close();

    EXPECT_EQ(
        // clang-format off
        lua->script(R"lua(
            f, err = io.open("testfile", "r")
            return err
        )lua").get<QString>(0),
       "Plugin does not have permissions to access given file."
        // clang-format on
    );

    const auto *shouldThrow1 = R"lua(
        io.input("testfile")
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow1));

    EXPECT_EQ(
        // clang-format off
        lua->script(R"lua(
            f, err = io.open("testfile", "w")
            return err
        )lua").get<QString>(0),
       "Plugin does not have permissions to access given file."
        // clang-format on
    );

    const auto *shouldThrow2 = R"lua(
        io.output("testfile")
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow2));

    const auto *shouldThrow3 = R"lua(
        io.lines("testfile")
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow3));
}

TEST_F(PluginTest, requireNoData)
{
    {
        PluginPermission ioread;
        PluginPermission iowrite;
        ioread.type = PluginPermission::Type::FilesystemRead;
        iowrite.type = PluginPermission::Type::FilesystemWrite;
        configure({ioread, iowrite});
    }

    auto file = rawpl->dataDirectory().filePath("thisiscode.lua");
    QFile f(file);
    EXPECT_TRUE(f.open(QFile::WriteOnly));
    f.write(R"lua(print("Data was executed"))lua");
    f.close();

    const auto *shouldThrow1 = R"lua(
        require("data.thisiscode")
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow1));
}

TEST_F(PluginTest, testTimerRec)
{
    configure();

    RequestWaiter waiter;
    lua->set("done", [&] {
        waiter.requestDone();
    });

    sol::protected_function fn = lua->script(R"lua(
        local i = 0
        f = function()
            i = i + 1
            c2.log(c2.LogLevel.Info, "cb", i)
            if i < 1024 then
                c2.later(f, 1)
            else
                done()
            end
        end
        c2.later(f, 1)
    )lua");
    waiter.waitForRequest(1ms);
}

TEST_F(PluginTest, tryCallTest)
{
    configure();
    lua->script(R"lua(
        function return_table()
            return {
                a="b"
            }
        end
        function return_nothing()
        end
        function return_nil()
            return nil
        end
        function return_nothing_and_error()
            error("I failed :)")
        end
    )lua");

    using func = sol::protected_function;

    func returnTable = lua->get<func>("return_table");
    func returnNil = lua->get<func>("return_nil");
    func returnNothing = lua->get<func>("return_nothing");
    func returnNothingAndError = lua->get<func>("return_nothing_and_error");

    // happy paths
    {
        auto res = lua::tryCall<sol::table>(returnTable);
        EXPECT_TRUE(res.has_value());
        auto t = res.value();
        EXPECT_EQ(t.get<QString>("a"), "b");
    }
    {
        // valid void return
        auto res = lua::tryCall<void>(returnNil);
        EXPECT_TRUE(res.has_value());
    }
    {
        // valid void return
        auto res = lua::tryCall<void>(returnNothing);
        EXPECT_TRUE(res.has_value());
    }
    {
        auto res = lua::tryCall<sol::table>(returnNothingAndError);
        EXPECT_FALSE(res.has_value());
        EXPECT_EQ(res.error(), "[string \"...\"]:13: I failed :)");
    }
    {
        auto res = lua::tryCall<std::optional<int>>(returnNil);
        EXPECT_TRUE(res.has_value());  // no error
        auto opt = *res;
        EXPECT_FALSE(opt.has_value());  // but also no false
    }

    // unhappy paths
    {
        // wrong return type
        auto res = lua::tryCall<int>(returnTable);
        EXPECT_FALSE(res.has_value());
        EXPECT_EQ(res.error(),
                  "Expected int to be returned but table was returned");
    }
    {
        // optional but bad return type
        auto res = lua::tryCall<std::optional<int>>(returnTable);
        EXPECT_FALSE(res.has_value());
        EXPECT_EQ(res.error(), "Expected std::optional<int> to be returned but "
                               "table was returned");
    }
    {
        // no return
        auto res = lua::tryCall<int>(returnNothing);
        EXPECT_FALSE(res.has_value());
        EXPECT_EQ(res.error(),
                  "Expected int to be returned but none was returned");
    }
    {
        // nil return
        auto res = lua::tryCall<int>(returnNil);
        EXPECT_FALSE(res.has_value());
        EXPECT_EQ(res.error(),
                  "Expected int to be returned but lua_nil was returned");
    }
}

TEST_F(PluginTest, testTcpWebSocket)
{
    configure({PluginPermission{{{"type", "Network"}}}});

    RequestWaiter waiter;
    std::vector<std::pair<bool, QByteArray>> messages;
    bool open = false;
    lua->set("done", [&] {
        waiter.requestDone();
    });
    lua->set("add", [&](bool isText, QByteArray data) {
        EXPECT_TRUE(open);
        messages.emplace_back(isText, std::move(data));
    });
    // On GCC in release mode, using set() would cause the done function to be called instead.
    lua->set_function("open", [&] {
        EXPECT_FALSE(open);
        open = true;
    });

    lua->set("url", "ws://" + PUBSUB_WS_ADDR + "/echo");

    std::shared_ptr<lua::api::WebSocket> ws = lua->script(R"lua(
        local ws = c2.WebSocket.new(url)
        ws.on_text = function(data)
            add(true, data)
        end
        local any_msg = false
        ws.on_binary = function(data)
            if not any_msg then
                any_msg = true
                ws:send_text(string.rep("a", 1 << 15))
                ws:send_binary("wow")
                ws:send_text("/HEADER user-agent")
                ws:send_binary("/CLOSE")
            end
            add(false, data)
        end
        ws.on_close = function()
            done()
        end
        ws.on_open = function()
            open()
        end
        ws:send_text("message1")
        ws:send_text("message2")
        ws:send_text("message3")
        ws:send_binary("message4")

        return ws
    )lua");
    std::weak_ptr<lua::api::WebSocket> weakWs{ws};
    ws.reset();

    waiter.waitForRequest();
    ASSERT_TRUE(open);

    ASSERT_EQ(messages.size(), 7);
    ASSERT_EQ(messages[0].first, true);
    ASSERT_EQ(messages[0].second, "message1");
    ASSERT_EQ(messages[1].first, true);
    ASSERT_EQ(messages[1].second, "message2");
    ASSERT_EQ(messages[2].first, true);
    ASSERT_EQ(messages[2].second, "message3");
    ASSERT_EQ(messages[3].first, false);
    ASSERT_EQ(messages[3].second, "message4");
    ASSERT_EQ(messages[4].first, true);
    ASSERT_EQ(messages[4].second, QByteArray(1 << 15, 'a'));
    ASSERT_EQ(messages[5].first, false);
    ASSERT_EQ(messages[5].second, "wow");
    ASSERT_EQ(messages[6].first, true);
    ASSERT_TRUE(messages[6].second.startsWith("Chatterino"));

    ASSERT_FALSE(weakWs.expired());
    lua->collect_garbage();
    ASSERT_TRUE(weakWs.expired());
}

TEST_F(PluginTest, testTlsWebSocket)
{
    configure({PluginPermission{{{"type", "Network"}}}});

    RequestWaiter waiter;
    bool open = false;
    std::vector<std::pair<bool, QByteArray>> messages;
    lua->set("done", [&] {
        waiter.requestDone();
    });
    lua->set("add", [&](bool isText, QByteArray data) {
        EXPECT_TRUE(open);
        messages.emplace_back(isText, std::move(data));
    });
    // On GCC in release mode, using set() would cause the done function to be called instead.
    lua->set_function("open", [&] {
        EXPECT_FALSE(open);
        open = true;
    });

    lua->set("url", "wss://" + PUBSUB_WSS_ADDR + "/echo");

    std::shared_ptr<lua::api::WebSocket> ws = lua->script(R"lua(
        local ws = c2.WebSocket.new(url, {
            headers = {
                ["User-Agent"] = "Lua",
                ["A-Header"] = "A value",
                ["Referer"] = "https://chatterino.com",
            },
        })
        ws.on_text = function(data)
            add(true, data)
        end
        local any_msg = false
        ws.on_binary = function(data)
            if not any_msg then
                any_msg = true
                ws:send_text(string.rep("a", 1 << 15))
                ws:send_binary("wow")
                ws:send_text("/HEADER user-agent")
                ws:send_text("/HEADER a-header")
                ws:send_text("/HEADER referer")
                ws:send_binary("/CLOSE")
            end
            add(false, data)
        end
        ws.on_close = function()
            done()
        end
        ws.on_open = function()
            open()
        end
        ws:send_text("message1")
        ws:send_text("message2")
        ws:send_text("message3")
        ws:send_binary("message4")

        return ws
    )lua");
    std::weak_ptr<lua::api::WebSocket> weakWs{ws};
    ws.reset();

    waiter.waitForRequest();
    ASSERT_TRUE(open);

    ASSERT_EQ(messages.size(), 9);
    ASSERT_EQ(messages[0].first, true);
    ASSERT_EQ(messages[0].second, "message1");
    ASSERT_EQ(messages[1].first, true);
    ASSERT_EQ(messages[1].second, "message2");
    ASSERT_EQ(messages[2].first, true);
    ASSERT_EQ(messages[2].second, "message3");
    ASSERT_EQ(messages[3].first, false);
    ASSERT_EQ(messages[3].second, "message4");
    ASSERT_EQ(messages[4].first, true);
    ASSERT_EQ(messages[4].second, QByteArray(1 << 15, 'a'));
    ASSERT_EQ(messages[5].first, false);
    ASSERT_EQ(messages[5].second, "wow");
    ASSERT_EQ(messages[6].first, true);
    ASSERT_EQ(messages[6].second, "Lua");
    ASSERT_EQ(messages[7].first, true);
    ASSERT_EQ(messages[7].second, "A value");
    ASSERT_EQ(messages[8].first, true);
    ASSERT_EQ(messages[8].second, "https://chatterino.com");

    ASSERT_FALSE(weakWs.expired());
    lua->collect_garbage();
    ASSERT_TRUE(weakWs.expired());
}

TEST_F(PluginTest, testWebSocketNoPerms)
{
    configure();

    bool res = lua->script(R"lua(
        return c2["WebSocket"] ~= nil
    )lua");
    ASSERT_TRUE(res);

    lua->set("url", "wss://" + PUBSUB_WSS_ADDR + "/echo");

    const char *shouldThrow = R"lua(
        return c2.WebSocket.new(url)
    )lua";
    EXPECT_ANY_THROW(lua->script(shouldThrow));
}

TEST_F(PluginTest, testWebSocketApi)
{
    configure({PluginPermission{{{"type", "Network"}}}});
    lua->set("url", "wss://" + PUBSUB_WSS_ADDR + "/echo");

    bool ok = lua->script(R"lua(
        local t = function () end
        local b = function () end
        local c = function () end
        local ws = c2.WebSocket.new(url, {
            on_text = t,
            on_binary = b,
            on_close = c,
        })

        return ws.on_text == t and ws.on_binary == b and ws.on_close == c
    )lua");

    ASSERT_TRUE(ok);
}

TEST_F(PluginTest, testWebSocketUnsetFns)
{
    configure({PluginPermission{{{"type", "Network"}}}});

    RequestWaiter waiter;
    lua->set("done", [&] {
        waiter.requestDone();
    });

    lua->set("url", "wss://" + PUBSUB_WSS_ADDR + "/echo");

    lua->script(R"lua(
        local ws = c2.WebSocket.new(url)
        ws.on_close = function()
            done()
        end
        ws:send_text("message1")
        ws:send_text("message2")
        ws:send_binary("message3")
        ws:send_binary("/CLOSE")
    )lua");

    waiter.waitForRequest();
}

TEST_F(PluginTest, MessageElementFlag)
{
    configure();
    lua->script(R"lua(
        values = {}
        for k, v in pairs(c2.MessageElementFlag) do
            table.insert(values, ("%s=0x%x"):format(k, v))
        end
        table.sort(values, function(a, b) return a:lower() < b:lower() end)
        out = table.concat(values, ",")
    )lua");

    const char *VALUES = "AlwaysShow=0x2000000,"
                         "BadgeBttv=0x40,"
                         "BadgeChannelAuthority=0x8000,"
                         "BadgeChatterino=0x40000,"
                         "BadgeFfz=0x80000,"
                         "BadgeGlobalAuthority=0x2000,"
                         "BadgePredictions=0x4000,"
                         "BadgeSevenTV=0x1000000000,"
                         "BadgeSharedChannel=0x2000000000,"
                         "BadgeSubscription=0x10000,"
                         "BadgeVanity=0x20000,"
                         "BitsAmount=0x200000,"
                         "BitsAnimated=0x1000,"
                         "BitsStatic=0x800,"
                         "ChannelName=0x100000,"
                         "ChannelPointReward=0x100,"
                         "Collapsed=0x4000000,"
                         "EmojiImage=0x800000,"
                         "EmojiText=0x1000000,"
                         "EmoteImage=0x10,"
                         "EmoteText=0x20,"
                         "LowercaseLinks=0x20000000,"
                         "Mention=0x8000000,"
                         "Misc=0x1,"
                         "ModeratorTools=0x400000,"
                         "RepliedMessage=0x100000000,"
                         "ReplyButton=0x200000000,"
                         "Text=0x2,"
                         "Timestamp=0x8,"
                         "Username=0x4";

    std::string got = (*lua)["out"];
    ASSERT_EQ(got, VALUES);
}

TEST_F(PluginTest, ChannelAddMessage)
{
    configure();
    lua->script(R"lua(
        function do_it(chan)
            local Repost = c2.MessageContext.Repost
            local Original = c2.MessageContext.Original
            chan:add_message(c2.Message.new({ id = "1" }))
            chan:add_message(c2.Message.new({ id = "2" }), Repost)
            chan:add_message(c2.Message.new({ id = "3" }), Original, nil)
            chan:add_message(c2.Message.new({ id = "4" }), Repost, c2.MessageFlag.DoNotLog)
            chan:add_message(c2.Message.new({ id = "5" }), Original, c2.MessageFlag.DoNotLog)
            chan:add_message(c2.Message.new({ id = "6" }), Original, c2.MessageFlag.System)
        end
    )lua");

    auto chan = std::make_shared<MockChannel>("mock");

    std::vector<MessagePtr> logged;
    EXPECT_CALL(this->app->logging, addMessage)
        .Times(3)
        .WillRepeatedly(
            [&](const auto &, const auto &msg, const auto &, const auto &) {
                logged.emplace_back(msg);
            });

    std::vector<std::pair<MessagePtr, std::optional<MessageFlags>>> added;
    std::ignore = chan->messageAppended.connect([&](auto &&...args) {
        added.emplace_back(std::forward<decltype(args)>(args)...);
    });

    (*lua)["do_it"](lua::api::ChannelRef(chan));

    ASSERT_EQ(added.size(), 6);
    ASSERT_EQ(added[0].first->id, "1");
    ASSERT_FALSE(added[0].second.has_value());
    ASSERT_EQ(added[1].first->id, "2");
    ASSERT_FALSE(added[1].second.has_value());
    ASSERT_EQ(added[2].first->id, "3");
    ASSERT_FALSE(added[2].second.has_value());
    ASSERT_EQ(added[3].first->id, "4");
    ASSERT_EQ(added[3].second, MessageFlags{MessageFlag::DoNotLog});
    ASSERT_EQ(added[4].first->id, "5");
    ASSERT_EQ(added[4].second, MessageFlags{MessageFlag::DoNotLog});
    ASSERT_EQ(added[5].first->id, "6");
    ASSERT_EQ(added[5].second, MessageFlags{MessageFlag::System});

    ASSERT_EQ(logged.size(), 3);
    ASSERT_EQ(added[0].first, logged[0]);
    ASSERT_EQ(added[2].first, logged[1]);
    ASSERT_EQ(added[5].first, logged[2]);
}

TEST_F(PluginTest, MessageFrozenFlag)
{
    configure();
    sol::protected_function isFrozenFn = lua->script(R"lua(
        return function(msg)
            return msg.frozen
        end
    )lua");
    sol::protected_function setFrozenFn = lua->script(R"lua(
        return function(msg, val)
            msg.frozen = val
        end
    )lua");

    auto liquid = std::make_shared<Message>();
    auto res = isFrozenFn(liquid);
    ASSERT_TRUE(res.valid());
    ASSERT_FALSE(res.get<bool>());

    auto frozen = std::make_shared<Message>();
    frozen->freeze();
    res = isFrozenFn(frozen);
    ASSERT_TRUE(res.valid());
    ASSERT_TRUE(res.get<bool>());

    // we shouldn't be able to modify the flag
    ASSERT_FALSE(setFrozenFn(liquid, true).valid());
    ASSERT_FALSE(setFrozenFn(liquid, false).valid());
    ASSERT_FALSE(setFrozenFn(frozen, true).valid());
    ASSERT_FALSE(setFrozenFn(frozen, false).valid());
}

TEST_F(PluginTest, MessageFlagModification)
{
    configure();
    sol::protected_function pfn = lua->script(R"lua(
        return function(msg)
            assert(msg.flags == c2.MessageFlag.Debug)
            msg.flags = c2.MessageFlag.System
            assert(msg.flags == c2.MessageFlag.System)
        end
    )lua");
    sol::protected_function isFrozenFn = lua->script(R"lua(
        return function(msg)
            return msg.frozen
        end
    )lua");

    auto liquid = std::make_shared<Message>();
    liquid->flags = MessageFlag::Debug;
    auto res = pfn(liquid);
    ASSERT_TRUE(res.valid());

    // for the flags, it shouldn't matter if the message is frozen
    auto frozen = std::make_shared<Message>();
    frozen->flags = MessageFlag::Debug;
    frozen->freeze();
    res = pfn(frozen);
    ASSERT_TRUE(res.valid());
}

TEST_F(PluginTest, MessageModification)
{
    configure();

    // Test that we can modify properties and that Lua sees the modification
    sol::table tests = lua->script(R"lua(
        return {
            function(msg)
                msg.parse_time = 1234567
            end,
            function(msg)
                assert(msg.id == "abc")
                msg.id = "1234"
                assert(msg.id == "1234")
            end,
            function(msg)
                assert(msg.search_text == "search")
                msg.search_text = "query"
                assert(msg.search_text == "query")
            end,
            function(msg)
                assert(msg.message_text == "msg")
                msg.message_text = "text"
                assert(msg.message_text == "text")
            end,
            function(msg)
                assert(msg.login_name == "login")
                msg.login_name = "name"
                assert(msg.login_name == "name")
            end,
            function(msg)
                assert(msg.display_name == "display")
                msg.display_name = "name"
                assert(msg.display_name == "name")
            end,
            function(msg)
                assert(msg.localized_name == "localized")
                msg.localized_name = "name"
                assert(msg.localized_name == "name")
            end,
            function(msg)
                assert(msg.user_id == "id")
                msg.user_id = "id"
                assert(msg.user_id == "id")
            end,
            function(msg)
                assert(msg.channel_name == "channel")
                msg.channel_name = "name"
                assert(msg.channel_name == "name")
            end,
            function(msg)
                assert(msg.username_color == "#ffaabbcc")
                msg.username_color = "#ccbbaaff"
                assert(msg.username_color == "#ccbbaaff")
            end,
            function(msg)
                assert(msg.server_received_time == 1230000)
                msg.server_received_time = 1240000
                assert(msg.server_received_time == 1240000)
            end,
            function(msg)
                print(msg.highlight_color)
                assert(msg.highlight_color == "#ff223344")
                msg.highlight_color = "#44332211"
                assert(msg.highlight_color == "#44332211")
            end,
            function(msg)
                assert(#msg:elements() == 2)
                msg:append_element({ type = "linebreak" })
                assert(#msg:elements() == 3)
                assert(msg:elements()[3].type == "linebreak")
            end,
        }
    )lua");

    auto makeMsg = [] {
        auto msg = std::make_shared<Message>();
        msg->flags = MessageFlag::Debug;
        msg->id = "abc";
        msg->searchText = "search";
        msg->messageText = "msg";
        msg->loginName = "login";
        msg->displayName = "display";
        msg->localizedName = "localized";
        msg->userID = "id";
        msg->channelName = "channel";
        msg->usernameColor = QColor(0xaabbcc);
        msg->serverReceivedTime = QDateTime::fromMSecsSinceEpoch(1230000);
        msg->highlightColor = std::make_shared<QColor>(0x223344);
        msg->elements.push_back(
            std::make_unique<TextElement>("lol", MessageElementFlag::Text));
        msg->elements.push_back(
            std::make_unique<TextElement>("wow", MessageElementFlag::Text));
        return msg;
    };

    auto liquid = makeMsg();
    ASSERT_TRUE(tests.valid());
    for (const auto &[_key, cb] : tests)
    {
        sol::protected_function pf = cb;
        auto res = pf(liquid);
        if (!res.valid())
        {
            sol::error err = res;
            ASSERT_TRUE(false) << err.what();
        }
    }

    // If the message is frozen, all modifications should fail with an error
    auto frozen = makeMsg();
    frozen->freeze();
    for (const auto &[key, cb] : tests)
    {
        sol::protected_function pf = cb;
        auto res = pf(frozen);
        ASSERT_FALSE(res.valid());
        sol::error err = res;
        ASSERT_EQ(std::string_view(err.what()), "Message is frozen");
    }
}

TEST_F(PluginTest, MessageConstness)
{
    configure();
    sol::protected_function pfn = lua->script(R"lua(
        return function(msg)
            assert(msg.login_name == "hello")
            msg.login_name = "alien"
        end
    )lua");

    auto msg = std::make_shared<Message>();
    msg->loginName = "hello";
    MessagePtr cmsg = msg;

    auto res = pfn(cmsg);
    ASSERT_TRUE(res.valid());
    cmsg->freeze();
    res = pfn(cmsg);
    ASSERT_FALSE(res.valid());
}

// Test that we can access properties of message elements
TEST_F(PluginTest, MessageElementAccess)
{
    configure();
    sol::protected_function pfn = lua->script(R"lua(
        return function(msg, idx, prop)
            return msg:elements()[idx][prop]
        end
    )lua");

    auto msg = std::make_shared<Message>();
    msg->elements.emplace_back(
        std::make_unique<TextElement>("my text", MessageElementFlag::Text));
    msg->elements.emplace_back(std::make_unique<SingleLineTextElement>(
        "single line", MessageElementFlag::Text));
    msg->elements.emplace_back(std::make_unique<CircularImageElement>(
        ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton));
    msg->elements.emplace_back(std::make_unique<MentionElement>(
        "display", "login", MessageColor::Text, MessageColor::System));

    msg->elements[1]->setTooltip("tooltip");
    msg->elements[2]->setTrailingSpace(false);
    msg->freeze();

    auto getAll = [&](std::string_view key) {
        return std::array{
            pfn(msg, 1, key).get<sol::object>(),
            pfn(msg, 2, key).get<sol::object>(),
            pfn(msg, 3, key).get<sol::object>(),
            pfn(msg, 4, key).get<sol::object>(),
        };
    };

    auto types = getAll("type");
    ASSERT_EQ(types[0].as<std::string>(), "text");
    ASSERT_EQ(types[1].as<std::string>(), "single-line-text");
    ASSERT_EQ(types[2].as<std::string>(), "circular-image");
    ASSERT_EQ(types[3].as<std::string>(), "mention");

    auto flags = getAll("flags");
    ASSERT_EQ(flags[0].as<MessageElementFlag>(), MessageElementFlag::Text);
    ASSERT_EQ(flags[1].as<MessageElementFlag>(), MessageElementFlag::Text);
    ASSERT_EQ(flags[2].as<MessageElementFlag>(),
              MessageElementFlag::ReplyButton);
    ASSERT_EQ(flags[3].as<MessageElementFlag>(),
              (MessageElementFlags(MessageElementFlag::Text,
                                   MessageElementFlag::Mention)));

    auto tooltips = getAll("tooltip");
    ASSERT_EQ(tooltips[0].as<std::string>(), "");
    ASSERT_EQ(tooltips[1].as<std::string>(), "tooltip");
    ASSERT_EQ(tooltips[2].as<std::string>(), "");
    ASSERT_EQ(tooltips[3].as<std::string>(), "");

    auto spaces = getAll("trailing_space");
    ASSERT_TRUE(spaces[0].as<bool>());
    ASSERT_TRUE(spaces[1].as<bool>());
    ASSERT_FALSE(spaces[2].as<bool>());
    ASSERT_TRUE(spaces[3].as<bool>());

    // Properties only found on _some_ elements should not error
    // (like non existent properties)
    auto paddings = getAll("padding");
    ASSERT_TRUE(paddings[0].is<std::nullptr_t>());
    ASSERT_TRUE(paddings[1].is<std::nullptr_t>());
    ASSERT_EQ(paddings[2].as<int>(), 2);
    ASSERT_TRUE(paddings[3].is<std::nullptr_t>());

    auto words = getAll("words");
    ASSERT_EQ(words[0].as<std::vector<std::string>>(),
              (std::vector<std::string>{"my", "text"}));
    ASSERT_EQ(words[1].as<std::vector<std::string>>(),
              (std::vector<std::string>{"single", "line"}));
    ASSERT_TRUE(words[2].is<std::nullptr_t>());
    // mention elements are also text elements
    ASSERT_EQ(words[3].as<std::vector<std::string>>(),
              (std::vector<std::string>{"display"}));

    auto userLogins = getAll("user_login_name");
    ASSERT_TRUE(userLogins[0].is<std::nullptr_t>());
    ASSERT_TRUE(userLogins[1].is<std::nullptr_t>());
    ASSERT_TRUE(userLogins[2].is<std::nullptr_t>());
    ASSERT_EQ(userLogins[3].as<std::string>(), "login");

    auto times = getAll("time");
    ASSERT_TRUE(times[0].is<std::nullptr_t>());
    ASSERT_TRUE(times[1].is<std::nullptr_t>());
    ASSERT_TRUE(times[2].is<std::nullptr_t>());
    ASSERT_TRUE(times[3].is<std::nullptr_t>());

    auto nonExistent = getAll("non_existent");
    ASSERT_TRUE(nonExistent[0].is<std::nullptr_t>());
    ASSERT_TRUE(nonExistent[1].is<std::nullptr_t>());
    ASSERT_TRUE(nonExistent[2].is<std::nullptr_t>());
    ASSERT_TRUE(nonExistent[3].is<std::nullptr_t>());

    auto links = getAll("link");
    ASSERT_TRUE(links[0].is<Link>());
    ASSERT_TRUE(links[1].is<Link>());
    ASSERT_TRUE(links[2].is<Link>());
    ASSERT_TRUE(links[3].is<Link>());

    // test that accessing anything outside the elements vector causes an error
    auto res = pfn(msg, 0, "flags");
    ASSERT_FALSE(res.valid());
    res = pfn(msg, 42, "flags");
    ASSERT_FALSE(res.valid());
}

// Test that we can modify properties of message elements
TEST_F(PluginTest, MessageElementModification)
{
    configure();
    sol::protected_function pfn = lua->script(R"lua(
        return function(msg, idx, prop, val)
            msg:elements()[idx][prop] = val
        end
    )lua");

    // same as MessageElementAccess...
    auto msg = std::make_shared<Message>();
    msg->elements.emplace_back(
        std::make_unique<TextElement>("my text", MessageElementFlag::Text));
    msg->elements.emplace_back(std::make_unique<SingleLineTextElement>(
        "single line", MessageElementFlag::Text));
    msg->elements.emplace_back(std::make_unique<CircularImageElement>(
        ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton));
    msg->elements.emplace_back(std::make_unique<MentionElement>(
        "display", "login", MessageColor::Text, MessageColor::System));

    msg->elements[1]->setTooltip("tooltip");
    msg->elements[2]->setTrailingSpace(false);
    // ...but we don't freeze the message here

    auto setAll = [&](std::string_view key, auto value) {
        for (size_t i = 1; i <= 4; i++)
        {
            EXPECT_TRUE(pfn(msg, i, key, value).valid());
        }
    };
    setAll("tooltip", "tool");
    ASSERT_EQ(msg->elements[0]->getTooltip(), "tool");
    ASSERT_EQ(msg->elements[1]->getTooltip(), "tool");
    ASSERT_EQ(msg->elements[2]->getTooltip(), "tool");
    ASSERT_EQ(msg->elements[3]->getTooltip(), "tool");

    setAll("trailing_space", false);
    ASSERT_FALSE(msg->elements[0]->hasTrailingSpace());
    ASSERT_FALSE(msg->elements[1]->hasTrailingSpace());
    ASSERT_FALSE(msg->elements[2]->hasTrailingSpace());
    ASSERT_FALSE(msg->elements[3]->hasTrailingSpace());

    pfn(msg, 1, "link", Link{Link::CopyToClipboard, "foo"});
    pfn(msg, 2, "link", Link{Link::CopyToClipboard, "foo"});
    pfn(msg, 3, "link", Link{Link::CopyToClipboard, "foo"});
    // can't modify links of mention elements
    ASSERT_FALSE(
        pfn(msg, 4, "link", Link{Link::CopyToClipboard, "foo"}).valid());

    ASSERT_EQ(msg->elements[0]->getLink().type, Link::CopyToClipboard);
    ASSERT_EQ(msg->elements[0]->getLink().value, "foo");
    ASSERT_EQ(msg->elements[0]->getTooltip(), "<b>Copy to clipboard</b>");
    ASSERT_EQ(msg->elements[1]->getLink().type, Link::CopyToClipboard);
    ASSERT_EQ(msg->elements[1]->getLink().value, "foo");
    ASSERT_EQ(msg->elements[2]->getLink().type, Link::CopyToClipboard);
    ASSERT_EQ(msg->elements[2]->getLink().value, "foo");

    auto expectErr = [&](std::string_view key, auto value) {
        for (size_t i = 1; i <= 4; i++)
        {
            auto result = pfn(msg, i, key, value);
            EXPECT_FALSE(result.valid()) << key;
        }
    };
    expectErr("type", "something");
    expectErr("trailing_space", "something");

    // can't set these types
    expectErr("link", Link{Link::ViewThread, "foo"});
    expectErr("link", Link{Link::AutoModAllow, "foo"});

    // We can't modify these yet
    expectErr("padding", 1);
    expectErr("background", 0xabcdef12);
    expectErr("words", QStringList{"a", "b"});
    expectErr("color", 0x1234);
    expectErr("style", FontStyle::ChatMedium);
    expectErr("lowercase", "abc");
    expectErr("original", "or");
    expectErr("fallback_color", "system");
    expectErr("user_color", "system");
    expectErr("user_login_name", "system");
    expectErr("time", 42);

    // test that accessing anything outside the elements vector causes an error
    auto res = pfn(msg, 0, "trailing_space", true);
    ASSERT_FALSE(res.valid());
    res = pfn(msg, 42, "trailing_space", true);
    ASSERT_FALSE(res.valid());

    // we can't modify anything on frozen messages
    msg->freeze();
    expectErr("tooltip", "tool");
    expectErr("trailing_space", false);
    expectErr("padding", 1);
}

/// Test that both C++ exceptions and luaL_error properly unwind the stack.
TEST_F(PluginTest, LuaUnwind)
{
    configure();

    size_t i = 0;
    lua->set_function(
        "do_something",
        [&](sol::this_state state, bool should_error, bool use_lua_error) {
            auto g = qScopeGuard([&] {
                ++i;
            });
            if (should_error)
            {
                if (use_lua_error)
                {
                    luaL_error(state.lua_state(), "My message");
                }
                else
                {
                    throw std::runtime_error("My message");
                }
            }
        });

    ASSERT_EQ(i, 0);

    ASSERT_TRUE(lua->do_string("do_something(false, false)").valid());
    ASSERT_EQ(i, 1);

    ASSERT_TRUE(lua->do_string("do_something(false, true)").valid());
    ASSERT_EQ(i, 2);

    ASSERT_FALSE(lua->do_string("do_something(true, false)").valid());
    ASSERT_EQ(i, 3);

    ASSERT_FALSE(lua->do_string("do_something(true, true)").valid());
    ASSERT_EQ(i, 4);
}

/// Test that we're running with the Lua version we're compiled against.
TEST_F(PluginTest, LuaVersion)
{
    configure();

    lua->set_function("check_it", [](sol::this_state state) {
        luaL_checkversion(state.lua_state());
    });
    ASSERT_TRUE(lua->script("check_it()").valid());

    static_assert(LUA_VERSION_NUM >= 504);
}

TEST_F(PluginTest, ChannelOnDisplayNameChanged)
{
    this->configure();

    bool gotEvent = false;
    this->lua->set_function("on_test_event", [&] {
        gotEvent = true;
    });

    auto chan = std::make_shared<MockChannel>("mock");
    this->lua->set("chan", lua::api::ChannelRef(chan));
    sol::protected_function init = this->lua->script(R"lua(
        hdl = nil
        return function(chan)
            hdl = chan:on_display_name_changed(on_test_event)
        end
    )lua");

    ASSERT_TRUE(init(lua::api::ChannelRef(chan)).valid());

    ASSERT_TRUE(this->lua->script("assert(hdl ~= nil)").valid());

    // regular delivery
    chan->displayNameChanged.invoke();
    ASSERT_TRUE(gotEvent);

    // blocked connection
    ASSERT_TRUE(this->lua->script("hdl:block()").valid());
    ASSERT_TRUE(this->lua->script("assert(hdl:is_blocked())").valid());

    gotEvent = false;
    chan->displayNameChanged.invoke();
    ASSERT_FALSE(gotEvent);

    // unblocked connection
    ASSERT_TRUE(this->lua->script("hdl:unblock()").valid());
    ASSERT_TRUE(this->lua->script("assert(not hdl:is_blocked())").valid());

    gotEvent = false;
    chan->displayNameChanged.invoke();
    ASSERT_TRUE(gotEvent);

    // disconnect
    ASSERT_TRUE(this->lua->script("hdl:disconnect()").valid());
    ASSERT_TRUE(this->lua->script("assert(not hdl:is_connected())").valid());

    gotEvent = false;
    ASSERT_FALSE(gotEvent);
}

class PluginMessageConstructionTest
    : public PluginTest,
      public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginMessageConstructionTest, Run)
{
    auto fixture = testlib::Snapshot::read("PluginMessageCtor", GetParam());

    configure();
    std::string script;
    if (fixture->input().isArray())
    {
        for (auto line : fixture->input().toArray())
        {
            script += line.toString().toStdString() + '\n';
        }
    }
    else
    {
        script = fixture->inputString().toStdString() + '\n';
    }

    script += "out = c2.Message.new(msg)";
    lua->script(script);

    Message *got = (*lua)["out"];

    ASSERT_TRUE(fixture->run(got->toJson(), UPDATE_SNAPSHOTS));
}

INSTANTIATE_TEST_SUITE_P(
    PluginMessageConstruction, PluginMessageConstructionTest,
    testing::ValuesIn(testlib::Snapshot::discover("PluginMessageCtor")));

class PluginJsonTest : public PluginTest,
                       public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginJsonTest, Run)
{
    configure();
    auto reg = lua->registry().size();
    auto globals = lua->globals().size();

    runLuaTest("json", GetParam(), *this->lua);

    for (size_t i = 0; i < 5; i++)
    {
        lua->collect_garbage();
    }
    // make sure we don't leak anything to globals or the registry
    // but give the registry some room of 3 slots (two from getting the debug library)
    EXPECT_LE(lua->registry().size(), reg + 3);
    EXPECT_EQ(lua->globals().size(), globals);
}

INSTANTIATE_TEST_SUITE_P(PluginJson, PluginJsonTest,
                         testing::ValuesIn(discoverLuaTests("json")));

class PluginMessageTest : public PluginTest,
                          public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginMessageTest, Run)
{
    this->configure();
    runLuaTest("message", GetParam(), *this->lua);
}

INSTANTIATE_TEST_SUITE_P(PluginMessage, PluginMessageTest,
                         testing::ValuesIn(discoverLuaTests("message")));

class PluginChannelTest : public PluginTest,
                          public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginChannelTest, Run)
{
    this->configure();
    runLuaTest("channel", GetParam(), *this->lua);
}

INSTANTIATE_TEST_SUITE_P(PluginChannel, PluginChannelTest,
                         testing::ValuesIn(discoverLuaTests("channel")));

class PluginImageTest : public PluginTest,
                        public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginImageTest, Run)
{
    this->configure({PluginPermission({{"type", "network"}})});
    runLuaTest("images", GetParam(), *this->lua);
}

TEST_F(PluginImageTest, NoPerms)
{
    this->configure();
    auto res = this->lua->safe_script(R"lua(
        local ok, err = pcall(c2.Image.from_url, "https://foo.bar")
        assert(not ok and err == "Missing network permission to create images")
        ok, err = pcall(c2.ImageSet.new)
        assert(not ok and err == "Missing network permission to create images")
        ok, err = pcall(c2.ImageSet.new, c2.Image.empty(), "https://foo.bar")
        assert(not ok and err == "Missing network permission to create images")
        -- should still be able to query images
        local img = c2.Image.empty()
        assert(img.url == "")
        assert(not img.animated)
        assert(not img.is_loaded)
        assert(img.is_empty)
        assert(img.width == 0)
        assert(img.height == 0)
        assert(img.scale == 1)
        assert(img.size[1] == img.width)
        assert(img.size[2] == img.height)
    )lua");
    ASSERT_TRUE(res.valid());
}

INSTANTIATE_TEST_SUITE_P(PluginImage, PluginImageTest,
                         testing::ValuesIn(discoverLuaTests("images")));

class PluginDateTimeTest : public PluginTest,
                           public ::testing::WithParamInterface<QString>
{
};
TEST_P(PluginDateTimeTest, Run)
{
    this->configure();
    runLuaTest("datetime", GetParam(), *this->lua);
}

INSTANTIATE_TEST_SUITE_P(PluginChannel, PluginDateTimeTest,
                         testing::ValuesIn(discoverLuaTests("datetime")));

// verify that all snapshots are included
TEST(PluginMessageConstructionTest, Integrity)
{
    ASSERT_FALSE(UPDATE_SNAPSHOTS);  // make sure fixtures are actually tested
}

TEST_F(PluginTest, testAccounts)
{
    configure();

    auto res = lua->script(R"lua(
        local current = c2.current_account()
        assert(current:login() == "testaccount_420")
        assert(current:id() == "117166826")
        assert(current:color() == nil) -- unset
        assert(not current:is_anon())
    )lua");
    ASSERT_TRUE(res.valid()) << res.get<sol::error>().what();
}

TEST_F(PluginTest, debugTraceback)
{
    configure();

    QString traceback = lua->script(R"lua(
        local function other()
            error("oh no")
        end
        local function main()
            local function inner()
                other()
            end
            inner()
        end

        local ok, res = xpcall(main, debug.traceback)
        return res
    )lua")
                            .get<QString>();
    ASSERT_TRUE(traceback.contains("[C]: in function 'error'"));
    ASSERT_TRUE(traceback.contains("[string \"...\"]:3: in upvalue 'other'"));
    ASSERT_TRUE(traceback.contains("[string \"...\"]:7: in local 'inner'"));
    ASSERT_TRUE(traceback.contains(
        "[string \"...\"]:9: in function <[string \"...\"]:5>"));
    ASSERT_TRUE(traceback.contains("[C]: in function 'xpcall'"));
    ASSERT_TRUE(traceback.contains("[string \"...\"]:12: in main chunk"));

    traceback = lua->script(R"lua(
        return debug.traceback()
    )lua")
                    .get<QString>();
    ASSERT_TRUE(traceback.contains("[string \"...\"]:2: in main chunk"));

    traceback = lua->script(R"lua(
        return debug.traceback("my message")
    )lua")
                    .get<QString>();
    ASSERT_TRUE(traceback.contains("my message"));
    ASSERT_TRUE(traceback.contains("[string \"...\"]:2: in main chunk"));

    traceback = lua->script(R"lua(
        local coro = coroutine.create(function ()
            coroutine.yield()
        end)
        coroutine.resume(coro)
        return debug.traceback(coro)
    )lua")
                    .get<QString>();
    ASSERT_TRUE(traceback.contains("[C]: in function 'coroutine.yield'"));
    ASSERT_TRUE(traceback.contains(
        "[string \"...\"]:3: in function <[string \"...\"]:2>"));

    auto msg = lua->script(R"lua(
        return debug.traceback(c2.Message.new({id = "who would do this"}))
    )lua")
                   .get<std::shared_ptr<Message>>();
    ASSERT_EQ(msg->id, "who would do this");
}

#endif
