
/*
 * Beat Rat -- configurable keybinds
 *
 * A separate small config file from settings.conf on purpose -- the
 * same "several small config files rather than one big one" shape a
 * future plugin system would want (each plugin could ship its own
 * keybinds.d/ entry, the way nvim splits config across files).
 */

#include "keybinds.h"
#include "log.h"

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>

typedef struct {
    const char *name;
    AppAction action;
} ActionName;

static const ActionName ACTION_NAMES[] = {
    { "quit", ACTION_QUIT },
    { "scrub_left", ACTION_SCRUB_LEFT },
    { "scrub_right", ACTION_SCRUB_RIGHT },
    { "zoom_in", ACTION_ZOOM_IN },
    { "zoom_out", ACTION_ZOOM_OUT },
    { "play_pause", ACTION_PLAY_PAUSE },
    { "restart", ACTION_RESTART },
    { "visual_select", ACTION_VISUAL_SELECT },
    { "visual_line_select", ACTION_VISUAL_LINE_SELECT },
    { "confirm_visual", ACTION_CONFIRM_VISUAL },
    { "cancel_selection", ACTION_CANCEL_SELECTION },
    { "toggle_loop_region", ACTION_TOGGLE_LOOP_REGION },
    { "toggle_loop_all", ACTION_TOGGLE_LOOP_ALL },
    { "highlight_up", ACTION_HIGHLIGHT_UP },
    { "highlight_down", ACTION_HIGHLIGHT_DOWN },
    { "highlight_page_up", ACTION_HIGHLIGHT_PAGE_UP },
    { "highlight_page_down", ACTION_HIGHLIGHT_PAGE_DOWN },
    { "toggle_nav_context", ACTION_TOGGLE_NAV_CONTEXT },
    { "jump_start", ACTION_JUMP_START },
    { "jump_end", ACTION_JUMP_END },
    { "copy", ACTION_COPY_BEATRAT },
    { "copy_system", ACTION_COPY_SYSTEM },
    { "cut", ACTION_CUT_BEATRAT },
    { "cut_system", ACTION_CUT_SYSTEM },
    { "void", ACTION_VOID },
    { "paste", ACTION_PASTE_BEATRAT },
    { "paste_system", ACTION_PASTE_SYSTEM },
    { "enter_insert_mode", ACTION_ENTER_INSERT_MODE },
    { "enter_fx_mode", ACTION_ENTER_FX_MODE },
    { "enter_beat_mode", ACTION_ENTER_BEAT_MODE },
    { "toggle_track_move", ACTION_TOGGLE_TRACK_MOVE },
    { "toggle_mute", ACTION_TOGGLE_MUTE },
};
#define NUM_ACTION_NAMES (int)(sizeof(ACTION_NAMES) / sizeof(ACTION_NAMES[0]))

static AppAction action_from_name(const char *name) {
    for (int i = 0; i < NUM_ACTION_NAMES; i++)
        if (strcmp(ACTION_NAMES[i].name, name) == 0)
            return ACTION_NAMES[i].action;
    return ACTION_NONE;
}

const char *keybinds_action_name(AppAction action) {
    for (int i = 0; i < NUM_ACTION_NAMES; i++)
        if (ACTION_NAMES[i].action == action)
            return ACTION_NAMES[i].name;
    return "?";
}

/* Only FX/BEAT are leader-chord-only right now (see DEFAULT_LEADER_CHORDS
 * below) -- everything else is a top-level binding. Centralized here so
 * the editor and the save/migration code don't each hardcode their own
 * copy of "which actions live in which table". */
int keybinds_action_is_leader_chord(AppAction action) {
    return action == ACTION_ENTER_FX_MODE || action == ACTION_ENTER_BEAT_MODE ||
           action == ACTION_TOGGLE_TRACK_MOVE;
}

/* Parses a "mod1+mod2+...+keyname" value in place: each '+'-delimited
 * token before the last is a modifier name (ctrl/control, shift,
 * super, alt -- case-insensitive), the last token is the key name itself.
 * NUL-terminates at each '+' it consumes (same as the single-modifier
 * version this replaced), so `value` is not reusable as a whole string
 * afterward -- callers only need *out_keyname. An unrecognized
 * modifier name is logged and simply dropped (that one bit is not
 * set), rather than failing the whole binding -- consistent with how
 * an unknown action or key name elsewhere in this file just drops that
 * one line and keeps going. */
static unsigned int parse_modifiers(char *value, char **out_keyname) {
    unsigned int mods = 0;
    char *keyname = value;
    char *plus;
    while ((plus = strchr(keyname, '+')) != NULL) {
        *plus = '\0';
        if (strcasecmp(keyname, "ctrl") == 0 || strcasecmp(keyname, "control") == 0)
            mods |= ControlMask;
        else if (strcasecmp(keyname, "shift") == 0)
            mods |= ShiftMask;
        else if (strcasecmp(keyname, "super") == 0)
            mods |= Mod4Mask;
        else if (strcasecmp(keyname, "alt") == 0)
            mods |= Mod1Mask;
        else
            LOG_WARN("keybinds: unknown modifier '%s', ignoring", keyname);
        keyname = plus + 1;
    }
    *out_keyname = keyname;
    return mods;
}

/* Reverse of parse_modifiers -- "ctrl+shift+c", or "none" for an
 * explicitly-disabled (NoSymbol) entry. Shared by keybinds_save() (one
 * line per binding) and keybinds_format_binding() (the editor's
 * human-readable display, comma-joined when an action has more than
 * one binding). */
static void format_mods_and_key(unsigned int mods, KeySym ks, char *buf, size_t buf_size) {
    buf[0] = '\0';
    if (mods & ControlMask) snprintf(buf + strlen(buf), buf_size - strlen(buf), "ctrl+");
    if (mods & ShiftMask)   snprintf(buf + strlen(buf), buf_size - strlen(buf), "shift+");
    if (mods & Mod4Mask)    snprintf(buf + strlen(buf), buf_size - strlen(buf), "super+");
    if (mods & Mod1Mask)    snprintf(buf + strlen(buf), buf_size - strlen(buf), "alt+");
    if (ks == NoSymbol) {
        snprintf(buf + strlen(buf), buf_size - strlen(buf), "none");
        return;
    }
    const char *name = XKeysymToString(ks);
    snprintf(buf + strlen(buf), buf_size - strlen(buf), "%s", name ? name : "?");
}

/* Deliberately not sharing settings.c's config_dir_path -- keybinds
 * lives in its own small, self-contained file rather than depending on
 * another module's internals. Resolves to ~/.config/beatrat/config,
 * the same directory settings.conf lives in (config/ sits alongside
 * an empty plugins/ folder under ~/.config/beatrat -- settings.c
 * creates that one, since it runs first). */
static void config_dir_path(char *buf, size_t buf_size) {
    const char *xdg = getenv("XDG_CONFIG_HOME");
    if (xdg && *xdg) {
        snprintf(buf, buf_size, "%s/beatrat/config", xdg);
        return;
    }
    const char *home = getenv("HOME");
    if (!home || !*home)
        home = ".";
    snprintf(buf, buf_size, "%s/.config/beatrat/config", home);
}

static void mkdir_p_local(const char *path) {
    char tmp[768];
    snprintf(tmp, sizeof(tmp), "%s", path);
    size_t len = strlen(tmp);
    if (len > 0 && tmp[len - 1] == '/')
        tmp[len - 1] = '\0';
    for (char *p = tmp + 1; *p; p++) {
        if (*p == '/') {
            *p = '\0';
            if (mkdir(tmp, 0755) != 0 && errno != EEXIST)
                LOG_WARN("keybinds: could not create '%s'", tmp);
            *p = '/';
        }
    }
    if (mkdir(tmp, 0755) != 0 && errno != EEXIST)
        LOG_WARN("keybinds: could not create '%s'", tmp);
}

/* keyname of "none" (any case) is NOT an error -- it's how a config
 * line explicitly disables an action, stored as a real entry with
 * keysym == NoSymbol rather than just leaving the action out (see
 * keybinds.h's migration note for why that distinction matters). Any
 * other unrecognized name still warns and drops the line, same as
 * always. */
static void add_binding(KeyBindMap *kb, const char *keyname, unsigned int mods, AppAction action) {
    if (kb->num_bindings >= KEYBINDS_MAX) {
        LOG_WARN("keybinds: binding table full (max %d), dropping '%s'", KEYBINDS_MAX, keyname);
        return;
    }
    KeySym ks;
    if (strcasecmp(keyname, "none") == 0) {
        ks = NoSymbol;
    } else {
        ks = XStringToKeysym(keyname);
        if (ks == NoSymbol) {
            LOG_WARN("keybinds: unknown key name '%s'", keyname);
            return;
        }
    }
    kb->bindings[kb->num_bindings].keysym = ks;
    kb->bindings[kb->num_bindings].modifiers = mods;
    kb->bindings[kb->num_bindings].action = action;
    kb->num_bindings++;
}

/* Same shape as add_binding() above (including the "none" convention),
 * but for the leader-chord table. */
static void add_leader_chord(KeyBindMap *kb, const char *keyname, unsigned int mods, AppAction action) {
    if (kb->num_leader_chords >= KEYBINDS_MAX_LEADER_CHORDS) {
        LOG_WARN("keybinds: leader chord table full (max %d), dropping '%s'", KEYBINDS_MAX_LEADER_CHORDS, keyname);
        return;
    }
    KeySym ks;
    if (strcasecmp(keyname, "none") == 0) {
        ks = NoSymbol;
    } else {
        ks = XStringToKeysym(keyname);
        if (ks == NoSymbol) {
            LOG_WARN("keybinds: unknown key name '%s'", keyname);
            return;
        }
    }
    kb->leader_chords[kb->num_leader_chords].keysym = ks;
    kb->leader_chords[kb->num_leader_chords].modifiers = mods;
    kb->leader_chords[kb->num_leader_chords].action = action;
    kb->num_leader_chords++;
}

/* The single source of truth for "what does a fresh install bind", AND
 * (via keybinds_load()'s migration pass below) for "what should get
 * added back for an action an older keybinds.conf never mentions".
 * Previously this was a sequence of add_binding() calls inside
 * load_defaults() with no structured way to ask "what's the default
 * for action X" after the fact -- which is exactly what the migration
 * needed, and why each new action used to need its own hand-written
 * compatibility shim. One action can have more than one entry here
 * (e.g. copy = y AND ctrl+c) -- the migration adds all of an action's
 * entries together, never just one. */
typedef struct {
    AppAction action;
    const char *keyname;
    unsigned int mods;
} DefaultBindingSpec;

static const DefaultBindingSpec DEFAULT_BINDINGS[] = {
    { ACTION_QUIT, "q", 0 },
    { ACTION_SCRUB_LEFT, "Left", 0 },
    { ACTION_SCRUB_RIGHT, "Right", 0 },
    { ACTION_ZOOM_IN, "equal", 0 }, /* '=' key, the unshifted '+' on most layouts */
    { ACTION_ZOOM_OUT, "minus", 0 },
    { ACTION_PLAY_PAUSE, "space", 0 },
    { ACTION_RESTART, "r", 0 },
    { ACTION_VISUAL_SELECT, "v", 0 },
    { ACTION_VISUAL_LINE_SELECT, "v", ShiftMask },
    { ACTION_CONFIRM_VISUAL, "Return", 0 },
    { ACTION_CANCEL_SELECTION, "Escape", 0 },
    { ACTION_TOGGLE_LOOP_REGION, "l", 0 },
    { ACTION_TOGGLE_LOOP_ALL, "l", ShiftMask },
    { ACTION_HIGHLIGHT_UP, "Up", 0 },
    { ACTION_HIGHLIGHT_DOWN, "Down", 0 },
    { ACTION_HIGHLIGHT_PAGE_UP, "Page_Up", 0 },
    { ACTION_HIGHLIGHT_PAGE_DOWN, "Page_Down", 0 },
    /* vim-style j/k alongside Up/Down -- plain aliases, not mode-gated
     * (unlike h/l, which only mean anything in INSERT mode and are
     * hardcoded in main.c's KeyPress handling instead -- see that
     * file's comment next to the INSERT h/l override for why bare 'l'
     * can't live in this flat keysym+mods table: it already means
     * toggle_loop_region above, and only INSERT overrides that). */
    { ACTION_HIGHLIGHT_DOWN, "j", 0 },
    { ACTION_HIGHLIGHT_UP, "k", 0 },
    /* Bare Tab -- promoted from a leader chord to its own top-level
     * bind, since toggling track/region selection mode is common
     * enough to want a single keypress rather than <leader> then Tab. */
    { ACTION_TOGGLE_NAV_CONTEXT, "Tab", 0 },
    /* vim-style start/end jumps. 'g' twice (bare, no modifier) is
     * hardcoded in main.c's KeyPress handling as a genuine two-tap
     * "gg" sequence -- not expressible in this single-key-plus-
     * modifiers table -- so it isn't listed here; Ctrl+A reaches the
     * same ACTION_JUMP_START as an ordinary single-press alternative. */
    { ACTION_JUMP_START, "a", ControlMask },
    { ACTION_JUMP_END, "g", ShiftMask }, /* Shift+g = 'G' */
    /* Copy/cut/void/paste -- both a standard-keyboard/emacs-style set
     * (Ctrl+c/x/v) and a vim-style set (y/d/x/p), each with a
     * system-register variant reachable via a modifier that signals
     * "the OTHER register": Shift for the Ctrl-style set, Super for
     * the vim-style set. */
    { ACTION_COPY_BEATRAT, "y", 0 },
    { ACTION_COPY_BEATRAT, "c", ControlMask },
    { ACTION_COPY_SYSTEM, "y", Mod4Mask },
    { ACTION_COPY_SYSTEM, "c", ControlMask | ShiftMask },
    { ACTION_CUT_BEATRAT, "d", 0 },
    { ACTION_CUT_BEATRAT, "x", ControlMask },
    { ACTION_CUT_SYSTEM, "d", Mod4Mask },
    { ACTION_CUT_SYSTEM, "x", ControlMask | ShiftMask },
    { ACTION_VOID, "x", 0 },
    { ACTION_PASTE_BEATRAT, "p", 0 },
    { ACTION_PASTE_BEATRAT, "v", ControlMask },
    { ACTION_PASTE_SYSTEM, "p", Mod4Mask },
    { ACTION_PASTE_SYSTEM, "v", ControlMask | ShiftMask },
    /* Interaction modes (see AppMode in main.c): bare 'i' enters INSERT
     * from NORMAL, vim-style -- Escape always returns to NORMAL from
     * any mode (that's cancel_selection above, not a separate action). */
    { ACTION_ENTER_INSERT_MODE, "i", 0 },
    /* Bare 'm' -- mute for playback (see keybinds.h's comment on
     * ACTION_TOGGLE_MUTE). Deliberately NOT the same keypress as
     * <leader>m (ACTION_TOGGLE_TRACK_MOVE) -- the leader prefix is
     * what disambiguates the two; this one is meant to be reachable
     * in a single press. */
    { ACTION_TOGGLE_MUTE, "m", 0 },
};
#define NUM_DEFAULT_BINDINGS (int)(sizeof(DEFAULT_BINDINGS) / sizeof(DEFAULT_BINDINGS[0]))

static const DefaultBindingSpec DEFAULT_LEADER_CHORDS[] = {
    /* FX and BEAT are reached as leader chords rather than bare keys,
     * since NORMAL mode is what gates the leader in the first place --
     * <leader>f / <leader>b. Both are backbone only right now --
     * switching into one just changes the mode indicator. */
    { ACTION_ENTER_FX_MODE, "f", 0 },
    { ACTION_ENTER_BEAT_MODE, "b", 0 },
    { ACTION_TOGGLE_TRACK_MOVE, "m", 0 },
};
#define NUM_DEFAULT_LEADER_CHORDS (int)(sizeof(DEFAULT_LEADER_CHORDS) / sizeof(DEFAULT_LEADER_CHORDS[0]))

static void load_defaults(KeyBindMap *kb) {
    kb->num_bindings = 0;
    for (int i = 0; i < NUM_DEFAULT_BINDINGS; i++)
        add_binding(kb, DEFAULT_BINDINGS[i].keyname, DEFAULT_BINDINGS[i].mods, DEFAULT_BINDINGS[i].action);

    /* Leader: press it, then a follow-up key, for things beyond the
     * single-key bindings above. Ctrl+space by default -- a plain
     * modifier+key combo like any other bind, so there's no tap-vs-hold
     * ambiguity to worry about (see main.c's KeyPress handling for the
     * "leader bit still held" disambiguation this enables). */
    kb->leader_keysym = XStringToKeysym("space");
    kb->leader_mods = ControlMask;
    kb->num_leader_chords = 0;
    for (int i = 0; i < NUM_DEFAULT_LEADER_CHORDS; i++)
        add_leader_chord(kb, DEFAULT_LEADER_CHORDS[i].keyname, DEFAULT_LEADER_CHORDS[i].mods,
                          DEFAULT_LEADER_CHORDS[i].action);
}

/* Shared by keybinds_load() (writing the file for the first time) and
 * keybinds_save() (rewriting it after a migration or an in-app rebind)
 * -- the explanatory text at the top never changes, only the bindings
 * below it do, so both call this and then write_bindings_body(). */
static void write_header(FILE *fp) {
    fprintf(fp,
        "# Beat Rat keybinds -- one action per line: action=key, action=ctrl+key,\n"
        "# action=shift+key, or stack modifiers (ctrl+shift+key, ...). Key names are\n"
        "# X11 keysym names (Left, Right, space, equal, minus, a-z, 0-9, Return,\n"
        "# Escape, ...). 'action=none' explicitly disables an action -- different\n"
        "# from deleting the line: a deleted line still gets the built-in default\n"
        "# added back the next time this file predates a newer build's actions (see\n"
        "# below), but 'none' is remembered as a deliberate choice and left alone.\n"
        "#\n"
        "# This file fully replaces the built-in defaults once it exists -- deleting\n"
        "# a line unbinds that action UNLESS this build knows an action this file\n"
        "# never mentions at all, in which case its default is added back in\n"
        "# automatically (and this file is rewritten to include it) -- that's what\n"
        "# keeps old config files working after an update adds something new,\n"
        "# without silently reverting anything you changed on purpose.\n"
        "#\n"
        "# leader=<key> sets the leader key (LazyVim-style: press it, then a\n"
        "# follow-up key). leader_<action>=<key> binds that follow-up.\n"
        "#\n"
        "# This file is also rewritten by the in-app Settings > Keybinds editor --\n"
        "# hand-added comments of your own below this header won't survive that.\n");
}

static void write_bindings_body(FILE *fp, const KeyBindMap *kb) {
    for (int i = 0; i < kb->num_bindings; i++) {
        char keybuf[64];
        format_mods_and_key(kb->bindings[i].modifiers, kb->bindings[i].keysym, keybuf, sizeof(keybuf));
        fprintf(fp, "%s=%s\n", keybinds_action_name(kb->bindings[i].action), keybuf);
    }
    if (kb->leader_keysym != NoSymbol) {
        char keybuf[64];
        format_mods_and_key(kb->leader_mods, kb->leader_keysym, keybuf, sizeof(keybuf));
        fprintf(fp, "leader=%s\n", keybuf);
    }
    for (int i = 0; i < kb->num_leader_chords; i++) {
        char keybuf[64];
        format_mods_and_key(kb->leader_chords[i].modifiers, kb->leader_chords[i].keysym, keybuf, sizeof(keybuf));
        fprintf(fp, "leader_%s=%s\n", keybinds_action_name(kb->leader_chords[i].action), keybuf);
    }
}

void keybinds_save(KeyBindMap *kb) {
    FILE *fp = fopen(kb->config_path, "w");
    if (!fp) {
        LOG_WARN("keybinds: could not write config to '%s'", kb->config_path);
        return;
    }
    write_header(fp);
    write_bindings_body(fp, kb);
    fclose(fp);
    LOG_INFO("keybinds: wrote %d binding(s), %d leader chord(s) to '%s'",
              kb->num_bindings, kb->num_leader_chords, kb->config_path);
}

/* True if ANY binding (enabled or explicitly "none") in kb maps to
 * `action` -- used both by the version-migration pass (skip an action
 * the file addressed at all, even to disable it) and the editor (to
 * find what's currently bound before offering to change it). */
static int action_bound_in(const KeyBindMap *kb, AppAction action) {
    for (int i = 0; i < kb->num_bindings; i++)
        if (kb->bindings[i].action == action)
            return 1;
    return 0;
}

static int action_bound_in_chords(const KeyBindMap *kb, AppAction action) {
    for (int i = 0; i < kb->num_leader_chords; i++)
        if (kb->leader_chords[i].action == action)
            return 1;
    return 0;
}

/* The generalized version-migration pass: for every action the
 * DEFAULT_BINDINGS/DEFAULT_LEADER_CHORDS tables above know about,
 * check whether the just-loaded file addressed it at all (bound to a
 * real key, or explicitly "none"). If not -- the file predates that
 * action entirely -- add back every default entry for it. Returns the
 * number of actions that got migrated, so the caller knows whether the
 * file needs rewriting. This is the one generic mechanism that
 * replaces what used to be a separate hand-written shim per feature
 * (register ops, then INSERT mode, then FX/BEAT -- each added by hand
 * in a previous round); adding a new action to the DEFAULT_* tables is
 * now the only thing a future feature needs to do to get this for free. */
static int migrate_missing_defaults(KeyBindMap *kb) {
    int migrated = 0;
    int seen[ACTION_COUNT];
    memset(seen, 0, sizeof(seen));

    for (int i = 0; i < NUM_DEFAULT_BINDINGS; i++) {
        AppAction a = DEFAULT_BINDINGS[i].action;
        if (seen[a]) continue;
        seen[a] = 1;
        if (action_bound_in(kb, a)) continue;
        for (int j = 0; j < NUM_DEFAULT_BINDINGS; j++)
            if (DEFAULT_BINDINGS[j].action == a)
                add_binding(kb, DEFAULT_BINDINGS[j].keyname, DEFAULT_BINDINGS[j].mods, a);
        LOG_INFO("keybinds: config predates action '%s' -- added its default binding(s)",
                  keybinds_action_name(a));
        migrated++;
    }

    memset(seen, 0, sizeof(seen));
    for (int i = 0; i < NUM_DEFAULT_LEADER_CHORDS; i++) {
        AppAction a = DEFAULT_LEADER_CHORDS[i].action;
        if (seen[a]) continue;
        seen[a] = 1;
        if (action_bound_in_chords(kb, a)) continue;
        for (int j = 0; j < NUM_DEFAULT_LEADER_CHORDS; j++)
            if (DEFAULT_LEADER_CHORDS[j].action == a)
                add_leader_chord(kb, DEFAULT_LEADER_CHORDS[j].keyname, DEFAULT_LEADER_CHORDS[j].mods, a);
        LOG_INFO("keybinds: config predates leader chord for '%s' -- added its default",
                  keybinds_action_name(a));
        migrated++;
    }

    return migrated;
}

void keybinds_load(KeyBindMap *kb) {
    load_defaults(kb);

    char dir[768];
    config_dir_path(dir, sizeof(dir));
    mkdir_p_local(dir);
    snprintf(kb->config_path, sizeof(kb->config_path), "%s/keybinds.conf", dir);

    FILE *fp = fopen(kb->config_path, "r");
    if (!fp) {
        keybinds_save(kb); /* first run: the in-memory defaults become the initial file */
        return;
    }

    kb->num_bindings = 0; /* the file fully replaces the defaults once it exists */
    kb->leader_keysym = NoSymbol;
    kb->leader_mods = 0;
    kb->num_leader_chords = 0;
    char line[256];
    while (fgets(line, sizeof(line), fp)) {
        line[strcspn(line, "\r\n")] = '\0';
        if (line[0] == '#' || line[0] == '\0')
            continue;
        char *eq = strchr(line, '=');
        if (!eq)
            continue;
        *eq = '\0';
        char *action_name = line;
        char *value = eq + 1;

        char *keyname;
        unsigned int mods = parse_modifiers(value, &keyname);

        if (strcmp(action_name, "leader") == 0) {
            KeySym ks = XStringToKeysym(keyname);
            if (ks == NoSymbol) {
                LOG_WARN("keybinds: unknown leader key name '%s'", keyname);
                continue;
            }
            kb->leader_keysym = ks;
            kb->leader_mods = mods;
            continue;
        }
        if (strncmp(action_name, "leader_", 7) == 0) {
            AppAction action = action_from_name(action_name + 7);
            if (action == ACTION_NONE) {
                LOG_WARN("keybinds: unknown leader action '%s', skipping", action_name);
                continue;
            }
            add_leader_chord(kb, keyname, mods, action);
            continue;
        }

        AppAction action = action_from_name(action_name);
        if (action == ACTION_NONE) {
            LOG_WARN("keybinds: unknown action '%s', skipping", action_name);
            continue;
        }
        add_binding(kb, keyname, mods, action);
    }
    fclose(fp);

    int migrated = migrate_missing_defaults(kb);
    if (migrated > 0) {
        LOG_INFO("keybinds: '%s' predated %d action(s) -- rewriting with their defaults added",
                  kb->config_path, migrated);
        keybinds_save(kb);
    }

    LOG_INFO("keybinds: loaded %d binding(s), %d leader chord(s) from '%s'",
              kb->num_bindings, kb->num_leader_chords, kb->config_path);
}

AppAction keybinds_lookup(const KeyBindMap *kb, KeySym keysym, unsigned int modifiers) {
    for (int i = 0; i < kb->num_bindings; i++)
        if (kb->bindings[i].keysym == keysym && kb->bindings[i].modifiers == modifiers)
            return kb->bindings[i].action;
    return ACTION_NONE;
}

void keybinds_format_binding(const KeyBindMap *kb, AppAction action, char *buf, size_t buf_size) {
    buf[0] = '\0';
    int found = 0;

    if (keybinds_action_is_leader_chord(action)) {
        for (int i = 0; i < kb->num_leader_chords; i++) {
            if (kb->leader_chords[i].action != action) continue;
            char one[64];
            format_mods_and_key(kb->leader_chords[i].modifiers, kb->leader_chords[i].keysym, one, sizeof(one));
            if (found) snprintf(buf + strlen(buf), buf_size - strlen(buf), ", ");
            snprintf(buf + strlen(buf), buf_size - strlen(buf), "<leader>%s", one);
            found = 1;
        }
    } else {
        for (int i = 0; i < kb->num_bindings; i++) {
            if (kb->bindings[i].action != action) continue;
            char one[64];
            format_mods_and_key(kb->bindings[i].modifiers, kb->bindings[i].keysym, one, sizeof(one));
            if (found) snprintf(buf + strlen(buf), buf_size - strlen(buf), ", ");
            snprintf(buf + strlen(buf), buf_size - strlen(buf), "%s", one);
            found = 1;
        }
    }

    if (!found)
        snprintf(buf, buf_size, "(unbound)");
}

int keybinds_rebind(KeyBindMap *kb, AppAction action, KeySym new_keysym, unsigned int new_mods,
                     char *conflict_msg, size_t conflict_msg_size) {
    if (conflict_msg && conflict_msg_size > 0)
        conflict_msg[0] = '\0';

    int is_chord = keybinds_action_is_leader_chord(action);

    /* Remove every existing entry for this action first -- rebinding
     * always collapses an action down to the one key just chosen, even
     * if it previously had two (e.g. copy's y + ctrl+c). */
    if (is_chord) {
        int w = 0;
        for (int i = 0; i < kb->num_leader_chords; i++)
            if (kb->leader_chords[i].action != action)
                kb->leader_chords[w++] = kb->leader_chords[i];
        kb->num_leader_chords = w;
    } else {
        int w = 0;
        for (int i = 0; i < kb->num_bindings; i++)
            if (kb->bindings[i].action != action)
                kb->bindings[w++] = kb->bindings[i];
        kb->num_bindings = w;
    }

    /* Stealing someone else's key: if new_keysym/new_mods was already
     * claimed by a DIFFERENT action, bump it (whichever table it lived
     * in -- a top-level binding and a leader chord never collide with
     * each other since one needs the leader held first, so only that
     * action's own table needs checking). */
    if (new_keysym != NoSymbol) {
        if (is_chord) {
            for (int i = 0; i < kb->num_leader_chords; i++) {
                if (kb->leader_chords[i].keysym == new_keysym && kb->leader_chords[i].modifiers == new_mods) {
                    if (conflict_msg && conflict_msg_size > 0)
                        snprintf(conflict_msg, conflict_msg_size, "unbound '%s' (<leader> was already this key)",
                                  keybinds_action_name(kb->leader_chords[i].action));
                    for (int j = i; j < kb->num_leader_chords - 1; j++)
                        kb->leader_chords[j] = kb->leader_chords[j + 1];
                    kb->num_leader_chords--;
                    break;
                }
            }
        } else {
            for (int i = 0; i < kb->num_bindings; i++) {
                if (kb->bindings[i].keysym == new_keysym && kb->bindings[i].modifiers == new_mods) {
                    if (conflict_msg && conflict_msg_size > 0)
                        snprintf(conflict_msg, conflict_msg_size, "unbound '%s' (was already this key)",
                                  keybinds_action_name(kb->bindings[i].action));
                    for (int j = i; j < kb->num_bindings - 1; j++)
                        kb->bindings[j] = kb->bindings[j + 1];
                    kb->num_bindings--;
                    break;
                }
            }
        }
    }

    if (is_chord) {
        if (kb->num_leader_chords >= KEYBINDS_MAX_LEADER_CHORDS) {
            LOG_WARN("keybinds: leader chord table full, can't rebind '%s'", keybinds_action_name(action));
            return -1;
        }
        kb->leader_chords[kb->num_leader_chords].keysym = new_keysym;
        kb->leader_chords[kb->num_leader_chords].modifiers = new_mods;
        kb->leader_chords[kb->num_leader_chords].action = action;
        kb->num_leader_chords++;
    } else {
        if (kb->num_bindings >= KEYBINDS_MAX) {
            LOG_WARN("keybinds: binding table full, can't rebind '%s'", keybinds_action_name(action));
            return -1;
        }
        kb->bindings[kb->num_bindings].keysym = new_keysym;
        kb->bindings[kb->num_bindings].modifiers = new_mods;
        kb->bindings[kb->num_bindings].action = action;
        kb->num_bindings++;
    }

    char keybuf[64];
    format_mods_and_key(new_mods, new_keysym, keybuf, sizeof(keybuf));
    LOG_INFO("keybinds: rebound '%s' -> %s", keybinds_action_name(action), keybuf);
    keybinds_save(kb);
    return 0;
}
