#include <filesystem>
#include <linux/input-event-codes.h>
#include <format>
#include <thread>
#include "../../shared.hpp"
#include "../../hyprctlCompat.hpp"
#include "../shared.hpp"
#include "tests.hpp"

using namespace Hyprutils::OS;
using namespace Hyprutils::Memory;
static std::string flagFile           = "/tmp/hyprtester-keybinds.txt";
static std::string shortChordFlagFile = "/tmp/hyprtester-keybinds-short-chord.txt";
static std::string longChordFlagFile  = "/tmp/hyprtester-keybinds-long-chord.txt";

static std::string pluginKeybindCmd(bool pressed, uint32_t modifier, uint32_t key) {
    return std::format("/eval hl.plugin.test.keybind({}, {}, {})", pressed ? 1 : 0, modifier, key);
}

static std::string pluginKeybindMaskCmd(bool pressed, const std::vector<uint8_t>& mods, uint32_t key) {
    uint32_t mask = 0;
    for (auto m : mods)
        mask |= (1 << (m - 1));

    return "/eval hl.plugin.test.keybind_modmask(" + std::to_string(pressed ? 1 : 0) + ", " + std::to_string(mask) + ", " + std::to_string(key) + ")";
}

static std::string pluginScrollCmd(int delta) {
    return std::format("/eval hl.plugin.test.scroll({})", delta);
}

static std::string pluginClickCmd(bool pressed, uint32_t button) {
    return std::format("/eval hl.plugin.test.click({}, {})", button, pressed ? 1 : 0);
}

// Because i don't feel like changing someone elses code.
enum eKeyboardModifierIndex : uint8_t {
    MOD_SHIFT = 1,
    MOD_CAPS,
    MOD_CTRL,
    MOD_ALT,
    MOD_MOD2,
    MOD_MOD3,
    MOD_META,
    MOD_MOD5
};

static void clearFlag() {
    std::filesystem::remove(flagFile);
}

static bool checkFlag() {
    bool exists = std::filesystem::exists(flagFile);
    clearFlag();
    return exists;
}

static bool attemptCheckFlag(int attempts, int intervalMs) {
    for (int i = 0; i < attempts; i++) {
        if (checkFlag())
            return true;

        std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
    }

    return false;
}

static bool attemptCheckFile(const std::string& file, int attempts, int intervalMs) {
    for (int i = 0; i < attempts; ++i) {
        if (std::filesystem::exists(file)) {
            std::filesystem::remove(file);
            return true;
        }

        std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs));
    }

    return false;
}

static std::string readKittyOutput() {
    std::string output = Tests::execAndGet("kitten @ --to unix:/tmp/hyprtester-kitty.sock get-text --extent all");
    // chop off shell prompt
    std::size_t pos = output.rfind("$");
    if (pos != std::string::npos) {
        pos += 1;
        if (pos < output.size())
            output.erase(0, pos);
    }
    // NLog::log("Kitty output: '{}'", output);
    return output;
}

static void awaitKittyPrompt() {
    // wait until we see the shell prompt, meaning it's ready for test inputs
    for (int i = 0; i < 10; i++) {
        std::string output = Tests::execAndGet("kitten @ --to unix:/tmp/hyprtester-kitty.sock get-text --extent all");
        if (output.rfind("$") == std::string::npos) {
            std::this_thread::sleep_for(std::chrono::milliseconds(200));
            continue;
        }
        return;
    }
    NLog::log("{}Error: timed out waiting for kitty prompt", Colors::RED);
}

static CUniquePointer<CProcess> spawnRemoteControlKitty() {
    auto kittyProc = Tests::spawnKitty("keybinds_test", {"-o", "allow_remote_control=yes", "--listen-on", "unix:/tmp/hyprtester-kitty.sock", "--config", "NONE", "/bin/sh"});
    // wait a bit to ensure shell prompt is sent, we are going to read the text after it
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    if (kittyProc)
        awaitKittyPrompt();
    return kittyProc;
}

// All the `SUBTEST`s below are supposed to be independent `TEST_CASE`s.
// But if isolated trivially, some of them fail.
// TODO: investigate and isolate tests by turning `SUBTEST`s into `TEST_CASE`s.

SUBTEST(bind) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'))", flagFile)), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // await flag
    EXPECT(attemptCheckFlag(20, 50), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(bindKey) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('Y', hl.dsp.exec_cmd('touch {}'))", flagFile)), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 0, 29)));
    // await flag
    EXPECT(attemptCheckFlag(20, 50), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('Y')"), "ok");
}

SUBTEST(overlappingChords) {
    std::filesystem::remove(shortChordFlagFile);
    std::filesystem::remove(longChordFlagFile);

    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q + K', hl.dsp.exec_cmd('touch " + longChordFlagFile + "'))"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + K', hl.dsp.exec_cmd('touch " + shortChordFlagFile + "'))"), "ok");

    OK(getFromSocket(pluginKeybindCmd(true, 7, 24)));
    OK(getFromSocket(pluginKeybindCmd(true, 7, 45)));
    EXPECT(attemptCheckFile(longChordFlagFile, 20, 50), true);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);
    OK(getFromSocket(pluginKeybindCmd(false, 7, 45)));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 24)));

    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Q + K'); hl.unbind('SUPER + K')"), "ok");
    std::filesystem::remove(shortChordFlagFile);
    std::filesystem::remove(longChordFlagFile);

    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q', hl.dsp.exec_cmd('touch " + shortChordFlagFile + "'))"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q + K', hl.dsp.exec_cmd('touch " + longChordFlagFile + "'))"), "ok");

    OK(getFromSocket(pluginKeybindCmd(true, 7, 24)));
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);
    OK(getFromSocket(pluginKeybindCmd(true, 7, 45)));
    EXPECT(attemptCheckFile(longChordFlagFile, 20, 50), true);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);
    OK(getFromSocket(pluginKeybindCmd(false, 7, 45)));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 24)));

    std::filesystem::remove(longChordFlagFile);
    OK(getFromSocket(pluginKeybindCmd(true, 7, 24)));
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 24)));
    EXPECT(attemptCheckFile(shortChordFlagFile, 20, 50), true);

    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Q'); hl.unbind('SUPER + Q + K')"), "ok");
    std::filesystem::remove(shortChordFlagFile);
    std::filesystem::remove(longChordFlagFile);

    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q', hl.dsp.exec_cmd('touch " + shortChordFlagFile + "'))"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q + K', hl.dsp.exec_cmd('touch " + longChordFlagFile + "'), { release = true })"), "ok");

    OK(getFromSocket(pluginKeybindCmd(true, 7, 24)));
    OK(getFromSocket(pluginKeybindCmd(true, 7, 45)));
    OK(getFromSocket(pluginKeybindCmd(false, 7, 24)));
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 45)));
    EXPECT(attemptCheckFile(longChordFlagFile, 20, 50), true);
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);

    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Q'); hl.unbind('SUPER + Q + K')"), "ok");
    std::filesystem::remove(shortChordFlagFile);
    std::filesystem::remove(longChordFlagFile);

    EXPECT(getFromSocket("/eval hl.bind('SUPER + K', hl.dsp.exec_cmd('touch " + shortChordFlagFile + "'), { release = true })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q + K', hl.dsp.exec_cmd('touch " + longChordFlagFile + "'), { release = true })"), "ok");

    OK(getFromSocket(pluginKeybindCmd(true, 7, 45)));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 45)));
    EXPECT(attemptCheckFile(shortChordFlagFile, 20, 50), true);
    EXPECT(std::filesystem::exists(longChordFlagFile), false);

    EXPECT(getFromSocket("/eval hl.unbind('SUPER + K'); hl.unbind('SUPER + Q + K')"), "ok");
    std::filesystem::remove(shortChordFlagFile);
    std::filesystem::remove(longChordFlagFile);

    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q', hl.dsp.exec_cmd('touch " + shortChordFlagFile + "'))"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q + K', hl.dsp.exec_cmd('touch " + longChordFlagFile + "'))"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Q + K + L', hl.dsp.exec_cmd('true'))"), "ok");

    OK(getFromSocket(pluginKeybindCmd(true, 7, 24)));
    OK(getFromSocket(pluginKeybindCmd(true, 7, 45)));
    OK(getFromSocket(pluginKeybindCmd(false, 7, 24)));
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    EXPECT(std::filesystem::exists(shortChordFlagFile), false);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 45)));
    EXPECT(attemptCheckFile(longChordFlagFile, 20, 50), true);

    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Q'); hl.unbind('SUPER + Q + K'); hl.unbind('SUPER + Q + K + L')"), "ok");
    std::filesystem::remove(shortChordFlagFile);
    std::filesystem::remove(longChordFlagFile);
}

SUBTEST(longPress) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ long_press = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // check no flag on short press
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    EXPECT(checkFlag(), false);
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}
SUBTEST(keyLongPress) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('Y', hl.dsp.exec_cmd('touch {}'), {{ long_press = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 0, 29)));
    // check no flag on short press
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    EXPECT(checkFlag(), false);
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('Y')"), "ok");
}

SUBTEST(longPressRelease) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ long_press = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // check no flag on short press
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    EXPECT(checkFlag(), false);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}
SUBTEST(longPressOnlyKeyRelease) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ long_press = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // check no flag on short press
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    EXPECT(checkFlag(), false);
    // release key, keep modifier
    OK(getFromSocket(pluginKeybindCmd(false, 7, 29)));
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), false);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(repeat) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ repeating = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // await flag
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // check that it continues repeating
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(keyRepeat) {
    // wait until flag becomes false (CI timing can vary)
    bool ok = false;
    for (int i = 0; i < 20; ++i) {
        if (!checkFlag()) {
            ok = true;
            break;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
    EXPECT(ok, true);

    EXPECT(getFromSocket(std::format("/eval hl.bind('Y', hl.dsp.exec_cmd('touch {}'), {{ repeating = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 0, 29)));
    // await flag
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    EXPECT(checkFlag(), true);
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // check that it continues repeating
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('Y')"), "ok");
}

SUBTEST(repeatRelease) {
    // wait until flag becomes false (CI timing can vary)
    bool ok = false;
    for (int i = 0; i < 20; ++i) {
        if (!checkFlag()) {
            ok = true;
            break;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }

    EXPECT(ok, true);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ repeating = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // await flag
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    EXPECT(checkFlag(), true);
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    clearFlag();
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), false);
    // check that it is not repeating
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(repeatOnlyKeyRelease) {
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ repeating = true }})", flagFile)), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // await flag
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), true);
    // release key, keep modifier
    OK(getFromSocket(pluginKeybindCmd(false, 7, 29)));
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    clearFlag();
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), false);
    // check that it is not repeating
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    EXPECT(checkFlag(), false);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(shortcutBind) {
    auto kittyProc = spawnRemoteControlKitty();
    if (!kittyProc) {
        FAIL_TEST("Could not spawn kitty");
    }

    // test SUPER.
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Y', hl.dsp.send_shortcut({ mods = '', key = 'q', window = 'activewindow' }))"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // release keybind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    std::string output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    EXPECT(output.find("q") != std::string::npos, true);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");

    // test SUPER + SHIFT (pick a different shortcut to avoid caching).
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + SHIFT + Y', hl.dsp.send_shortcut({ mods = '', key = 'w', window = 'activewindow' }))"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindMaskCmd(true, {MOD_META, MOD_SHIFT}, 29)));
    // release keybind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    OK(getFromSocket(pluginKeybindMaskCmd(false, {}, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    EXPECT_COUNT_STRING(output, "Y", 0);
    EXPECT(output.find("w") != std::string::npos, true);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + SHIFT + Y')"), "ok");

    // test SUPER + SHIFT (check numbers (1 -> ! is not captured by case sensitivity)).
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + SHIFT + Y', hl.dsp.send_shortcut({ mods = '', key = '1', window = 'activewindow' }))"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindMaskCmd(true, {MOD_META, MOD_SHIFT}, 29)));
    // release keybind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    OK(getFromSocket(pluginKeybindMaskCmd(false, {}, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    EXPECT_COUNT_STRING(output, "Y", 0);
    EXPECT(output.find("1") != std::string::npos, true);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + SHIFT + Y')"), "ok");

    Tests::killAllWindows();
}

SUBTEST(shortcutBindKey) {
    auto kittyProc = spawnRemoteControlKitty();
    if (!kittyProc) {
        FAIL_TEST("Could not spawn kitty");
    }

    // test lowercase Y.
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('Y', hl.dsp.send_shortcut({ mods = '', key = 'e', window = 'activewindow' }))"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 0, 29)));
    // release keybind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    std::string output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    // disabled: doesn't work in CI
    // EXPECT_COUNT_STRING(output, "e", 1);
    EXPECT(getFromSocket("/eval hl.unbind('Y')"), "ok");

    // test SHIFT + Y (pick a different shortcut to avoid caching).
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SHIFT + Y', hl.dsp.send_shortcut({ mods = '', key = 'r', window = 'activewindow' }))"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindMaskCmd(true, {MOD_SHIFT}, 29)));
    // release keybind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    OK(getFromSocket(pluginKeybindMaskCmd(false, {}, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    EXPECT_COUNT_STRING(output, "Y", 0);
    // disabled: doesn't work in CI
    // EXPECT_COUNT_STRING(output, "r", 1);
    EXPECT(getFromSocket("/eval hl.unbind('SHIFT + Y')"), "ok");

    // test SHIFT + Y (check numbers (2 -> @ is not captured by case sensitivity)).
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SHIFT + Y', hl.dsp.send_shortcut({ mods = '', key = '2', window = 'activewindow' }))"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindMaskCmd(true, {MOD_SHIFT}, 29)));
    // release keybind
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    OK(getFromSocket(pluginKeybindMaskCmd(false, {}, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    EXPECT_COUNT_STRING(output, "Y", 0);
    // disabled: doesn't work in CI
    // EXPECT_COUNT_STRING(output, "2", 1);
    EXPECT(getFromSocket("/eval hl.unbind('SHIFT + Y')"), "ok");

    Tests::killAllWindows();
}

SUBTEST(shortcutLongPress) {
    auto kittyProc = spawnRemoteControlKitty();
    if (!kittyProc) {
        FAIL_TEST("Could not spawn kitty");
    }
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Y', hl.dsp.send_shortcut({ mods = '', key = 'q', window = 'activewindow' }), { long_press = true })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_rate = 10 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    const std::string output = readKittyOutput();
    int               yCount = Tests::countOccurrences(output, "y");
    int               qCount = Tests::countOccurrences(output, "q");
    // sometimes 1, sometimes 2, not sure why
    // keybind press sends 1 y immediately
    // then repeat triggers, sending 1 y
    // final release stop repeats, and shouldn't send any more
    EXPECT(true, yCount == 1 || yCount == 2);
    EXPECT(true, qCount == 1 || qCount == 2);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
    Tests::killAllWindows();
}

SUBTEST(shortcutLongPressKeyRelease) {
    auto kittyProc = spawnRemoteControlKitty();
    if (!kittyProc) {
        FAIL_TEST("Could not spawn kitty");
    }
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Y', hl.dsp.send_shortcut({ mods = '', key = 'q', window = 'activewindow' }), { long_press = true })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 100 } })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_rate = 10 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    // release key, keep modifier
    OK(getFromSocket(pluginKeybindCmd(false, 7, 29)));
    // await repeat delay
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    const std::string output = readKittyOutput();
    // disabled: doesn't work on CI
    // EXPECT_COUNT_STRING(output, "y", 1);
    EXPECT_COUNT_STRING(output, "q", 0);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
    Tests::killAllWindows();
}

SUBTEST(shortcutRepeat) {
    auto kittyProc = spawnRemoteControlKitty();
    if (!kittyProc) {
        FAIL_TEST("Could not spawn kitty");
    }
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Y', hl.dsp.send_shortcut({ mods = '', key = 'q', window = 'activewindow' }), { repeating = true })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_rate = 5 } })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 200 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    // await repeat
    std::this_thread::sleep_for(std::chrono::milliseconds(210));
    // release keybind
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(450));
    const std::string output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    int qCount = Tests::countOccurrences(output, "q");
    // sometimes 2, sometimes 3, not sure why
    // keybind press sends 1 q immediately
    // then repeat triggers, sending 1 q
    // final release stop repeats, and shouldn't send any more
    EXPECT(true, qCount == 2 || qCount == 3);
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
    Tests::killAllWindows();
}

SUBTEST(shortcutRepeatKeyRelease) {
    auto kittyProc = spawnRemoteControlKitty();
    if (!kittyProc) {
        FAIL_TEST("Could not spawn kitty");
    }
    EXPECT(getFromSocket("/dispatch hl.dsp.focus({ window = 'class:keybinds_test' })"), "ok");
    EXPECT(getFromSocket("/eval hl.bind('SUPER + Y', hl.dsp.send_shortcut({ mods = '', key = 'q', window = 'activewindow' }), { repeating = true })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_rate = 5 } })"), "ok");
    EXPECT(getFromSocket("r/eval hl.config({ input = { repeat_delay = 200 } })"), "ok");
    // press keybind
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    std::this_thread::sleep_for(std::chrono::milliseconds(210));
    // release key, keep modifier
    OK(getFromSocket(pluginKeybindCmd(false, 7, 29)));
    // if repeat was still active, we'd get 2 more q's here
    std::this_thread::sleep_for(std::chrono::milliseconds(450));
    // release modifier
    const std::string output = readKittyOutput();
    EXPECT_COUNT_STRING(output, "y", 0);
    int qCount = Tests::countOccurrences(output, "q");
    // sometimes 2, sometimes 3, not sure why
    // keybind press sends 1 q immediately
    // then repeat triggers, sending 1 q
    // final release stop repeats, and shouldn't send any more
    EXPECT(true, qCount == 2 || qCount == 3);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
    Tests::killAllWindows();
}

SUBTEST(submap) {
    const auto press = [](const uint32_t key, const uint32_t mod = 0) {
        // +8 because udev -> XKB keycode.
        getFromSocket(pluginKeybindCmd(true, mod, key + 8));
        getFromSocket(pluginKeybindCmd(false, mod, key + 8));
    };

    NLog::log("{}Testing submaps", Colors::GREEN);
    // submap 1 no resets
    press(KEY_U, MOD_META);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");
    press(KEY_O);
    Tests::waitUntilWindowsN(1);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");
    // submap 2 resets to submap 1
    press(KEY_U);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap2");
    press(KEY_O);
    Tests::waitUntilWindowsN(2);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");
    // submap 3 resets to default
    press(KEY_I);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap3");
    press(KEY_O);
    Tests::waitUntilWindowsN(3);
    EXPECT_CONTAINS(getFromSocket("/submap"), "default");
    // submap 1 reset via keybind
    press(KEY_U, MOD_META);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");
    press(KEY_P);
    EXPECT_CONTAINS(getFromSocket("/submap"), "default");

    // submap 1 with identical keybind bound inside it
    press(KEY_U, MOD_META);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");
    press(KEY_U, MOD_META);
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap3");
    press(KEY_O);
    Tests::waitUntilWindowsN(4);
    EXPECT_CONTAINS(getFromSocket("/submap"), "default");
    Tests::killAllWindows();
}

SUBTEST(submapMouseBinds) {
    // regression #14856: scroll / mouse-button binds in a non-default submap stopped firing
    NLog::log("{}Testing mouse binds inside a submap", Colors::GREEN);

    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket("r/eval hl.config({ binds = { scroll_event_delay = 0 } })"), "ok");

    // enter submap1 (SUPER+U); release with mods cleared so the modless mouse binds match
    getFromSocket(pluginKeybindCmd(true, 7, 30));
    getFromSocket(pluginKeybindCmd(false, 0, 30));
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");

    // scroll
    OK(getFromSocket(pluginScrollCmd(120)));
    EXPECT(attemptCheckFlag(20, 50), true);

    // mouse-button
    OK(getFromSocket(pluginClickCmd(true, BTN_LEFT)));
    OK(getFromSocket(pluginClickCmd(false, BTN_LEFT)));
    EXPECT(attemptCheckFlag(20, 50), true);

    // reset to default submap
    getFromSocket(pluginKeybindCmd(true, 0, 33));
    getFromSocket(pluginKeybindCmd(false, 0, 33));
    EXPECT_CONTAINS(getFromSocket("/submap"), "default");
}

SUBTEST(bindsAfterScroll) {
    NLog::log("{}Testing binds after scroll", Colors::GREEN);

    clearFlag();
    OK(getFromSocket(std::format("/eval hl.bind('ALT + w', hl.dsp.exec_cmd('touch {}'))", flagFile)));

    // press keybind before scroll
    OK(getFromSocket(pluginKeybindCmd(true, 0, 108))); // Alt_R press
    OK(getFromSocket(pluginKeybindCmd(true, 4, 25)));  // w press
    EXPECT(attemptCheckFlag(20, 50), true);
    OK(getFromSocket(pluginKeybindCmd(false, 4, 25)));  // w release
    OK(getFromSocket(pluginKeybindCmd(false, 0, 108))); // Alt_R release

    // scroll
    OK(getFromSocket(pluginScrollCmd(120)));
    OK(getFromSocket(pluginScrollCmd(-120)));
    OK(getFromSocket(pluginScrollCmd(120)));

    // press keybind after scroll
    OK(getFromSocket(pluginKeybindCmd(true, 0, 108))); // Alt_R press
    OK(getFromSocket(pluginKeybindCmd(true, 4, 25)));  // w press
    EXPECT(attemptCheckFlag(20, 50), true);
    OK(getFromSocket(pluginKeybindCmd(false, 4, 25)));  // w release
    OK(getFromSocket(pluginKeybindCmd(false, 0, 108))); // Alt_R release

    clearFlag();
    OK(getFromSocket("/eval hl.unbind('ALT + w')"));
}

SUBTEST(submapUniversal) {
    NLog::log("{}Testing submap universal", Colors::GREEN);

    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ submap_universal = true }})", flagFile)), "ok");
    EXPECT_CONTAINS(getFromSocket("/submap"), "default");

    // keybind works on default submap
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    OK(getFromSocket(pluginKeybindCmd(false, 7, 29)));
    EXPECT(attemptCheckFlag(30, 5), true);

    // keybind works on submap1
    getFromSocket(pluginKeybindCmd(true, 7, 30));
    getFromSocket(pluginKeybindCmd(false, 7, 30));
    EXPECT_CONTAINS(getFromSocket("/submap"), "submap1");
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    OK(getFromSocket(pluginKeybindCmd(false, 7, 29)));
    EXPECT(attemptCheckFlag(30, 5), true);

    // reset to default submap
    getFromSocket(pluginKeybindCmd(true, 0, 33));
    getFromSocket(pluginKeybindCmd(false, 0, 33));
    EXPECT_CONTAINS(getFromSocket("/submap"), "default");

    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(perDeviceKeybind) {
    NLog::log("{}Testing per-device binds", Colors::GREEN);

    // Inclusive
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ device = {{ inclusive = true, list = {{ 'test-keyboard-1' }} }} }})", flagFile)),
           "ok");
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    EXPECT(attemptCheckFlag(20, 50), true);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");

    // Exclusive
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ device = {{ inclusive = false, list = {{ 'test-keyboard-1' }} }} }})", flagFile)),
           "ok");
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    EXPECT(attemptCheckFlag(20, 50), false);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");

    // With description
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format(
               "/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ description = 'test description', device = {{ inclusive = true, list = {{ 'test-keyboard-1' }} }} }})",
               flagFile)),
           "ok");
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    EXPECT(attemptCheckFlag(20, 50), true);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");

    // Tags
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ device = {{ inclusive = true, list = {{ 'test-tag' }} }} }})", flagFile)), "ok");
    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    EXPECT(attemptCheckFlag(20, 50), true);
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));
    EXPECT(getFromSocket("/eval hl.unbind('SUPER + Y')"), "ok");
}

SUBTEST(unbind) {
    NLog::log("{}Testing unbind behavior", Colors::GREEN);

    // unbind should normalize the string: no spaces, lowercase OK
    EXPECT(checkFlag(), false);
    EXPECT(getFromSocket(std::format("/eval hl.bind('SUPER + Y', hl.dsp.exec_cmd('touch {}'), {{ device = {{ inclusive = true, list = {{ 'test-keyboard-1' }} }} }})", flagFile)),
           "ok");
    EXPECT(getFromSocket("/eval hl.unbind('   super     +   y      ')"), "ok");

    OK(getFromSocket(pluginKeybindCmd(true, 7, 29)));
    OK(getFromSocket(pluginKeybindCmd(false, 0, 29)));

    EXPECT(attemptCheckFlag(20, 50), false);
}

// TODO: remove this test after subtests above are properly isolated into independent tests
TEST_CASE(keybinds) {
    CALL_SUBTEST(bind);
    CALL_SUBTEST(bindKey);
    CALL_SUBTEST(overlappingChords);
    CALL_SUBTEST(longPress);
    CALL_SUBTEST(keyLongPress);
    CALL_SUBTEST(longPressRelease);
    CALL_SUBTEST(longPressOnlyKeyRelease);
    CALL_SUBTEST(repeat);
    CALL_SUBTEST(keyRepeat);
    CALL_SUBTEST(repeatRelease);
    CALL_SUBTEST(repeatOnlyKeyRelease);
    CALL_SUBTEST(shortcutBind);
    CALL_SUBTEST(shortcutBindKey);
    CALL_SUBTEST(shortcutLongPress);
    CALL_SUBTEST(shortcutLongPressKeyRelease);
    CALL_SUBTEST(shortcutRepeat);
    CALL_SUBTEST(shortcutRepeatKeyRelease);
    CALL_SUBTEST(submap);
    CALL_SUBTEST(submapMouseBinds);
    CALL_SUBTEST(submapUniversal);
    CALL_SUBTEST(bindsAfterScroll);
    CALL_SUBTEST(perDeviceKeybind);
    CALL_SUBTEST(unbind);
}
