const std = @import("std");
const vaxis = @import("vaxis");
const srf = @import("srf");

pub const Action = enum {
    quit,
    refresh,
    prev_tab,
    next_tab,
    tab_1,
    tab_2,
    tab_3,
    tab_4,
    tab_5,
    tab_6,
    tab_7,
    tab_8,
    tab_9,
    scroll_down,
    scroll_up,
    scroll_top,
    scroll_bottom,
    page_down,
    page_up,
    select_next,
    select_prev,
    symbol_input,
    help,
    reload_portfolio,
};

pub const KeyCombo = struct {
    codepoint: u21,
    mods: vaxis.Key.Modifiers = .{},

    /// SRF custom parser. Used by `srf.FieldIterator.to(...)` to coerce
    /// a `key::ctrl+c` field into a `KeyCombo` value. Returns
    /// `error.CustomParseFailed` (via the srf-level wrapping) on
    /// invalid input. Returns `CoercionResult(...).initFree(...)` so
    /// SRF frees the consumed source string after parsing.
    pub fn srfParse(val: []const u8) !srf.CoercionResult(KeyCombo) {
        return .initFree(parseKeyCombo(val) orelse return error.InvalidKeyCombo);
    }
};

pub const Binding = struct {
    action: Action,
    key: KeyCombo,
};

/// A user-specified keybinding scoped to a particular tab. The
/// `action_name` is the bare tag name from the tab's local
/// `Action` enum (e.g. "collapse_all_calls" for scope "options").
/// Resolution to the typed enum happens in the dispatcher at the
/// use site, where the tab's Action type is known.
pub const ScopedBinding = struct {
    action_name: []const u8,
    key: KeyCombo,
};

/// User overrides for a specific tab's keymap. When present in a
/// loaded `KeyMap`, the dispatcher consults these instead of the
/// tab module's `default_bindings`. Per the "user file replaces
/// defaults" semantics: presence of any record with this scope
/// fully replaces the tab's defaults (no merging).
pub const TabOverrides = struct {
    /// Tag name matching a `Tab` enum variant / `tab_modules`
    /// registry field (e.g. "options", "history").
    scope: []const u8,
    bindings: []const ScopedBinding,
};

pub const KeyMap = struct {
    bindings: []const Binding,
    /// Per-tab user overrides loaded from keys.srf. Empty when no
    /// scoped records were present (or when using defaults).
    tab_overrides: []const TabOverrides = &.{},
    /// Per-record parse warnings (e.g. unknown action name,
    /// malformed key string). Owned by `arena`. Empty when all
    /// records parsed cleanly OR when this keymap is the built-in
    /// defaults. Callers are expected to surface these to stderr.
    warnings: []const []const u8 = &.{},
    arena: ?*std.heap.ArenaAllocator = null,

    pub fn deinit(self: *KeyMap) void {
        if (self.arena) |a| {
            const backing = a.child_allocator;
            a.deinit();
            backing.destroy(a);
        }
    }

    pub fn matchAction(self: KeyMap, key: vaxis.Key) ?Action {
        for (self.bindings) |b| {
            if (key.matches(b.key.codepoint, b.key.mods)) return b.action;
        }
        return null;
    }

    /// Returns the user-override bindings for `scope`, or null if
    /// the scope has no overrides (caller should fall back to the
    /// tab module's `default_bindings`).
    pub fn tabOverridesFor(self: KeyMap, scope: []const u8) ?[]const ScopedBinding {
        for (self.tab_overrides) |to| {
            if (std.mem.eql(u8, to.scope, scope)) return to.bindings;
        }
        return null;
    }
};

// ── Defaults ─────────────────────────────────────────────────

pub const global_default_bindings = [_]Binding{
    .{ .action = .quit, .key = .{ .codepoint = 'q' } },
    .{ .action = .quit, .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } },
    .{ .action = .refresh, .key = .{ .codepoint = 'r' } },
    .{ .action = .refresh, .key = .{ .codepoint = vaxis.Key.f5 } },
    .{ .action = .prev_tab, .key = .{ .codepoint = 'h' } },
    .{ .action = .prev_tab, .key = .{ .codepoint = vaxis.Key.left } },
    .{ .action = .prev_tab, .key = .{ .codepoint = vaxis.Key.tab, .mods = .{ .shift = true } } },
    .{ .action = .next_tab, .key = .{ .codepoint = 'l' } },
    .{ .action = .next_tab, .key = .{ .codepoint = vaxis.Key.right } },
    .{ .action = .next_tab, .key = .{ .codepoint = vaxis.Key.tab } },
    .{ .action = .tab_1, .key = .{ .codepoint = '1' } },
    .{ .action = .tab_2, .key = .{ .codepoint = '2' } },
    .{ .action = .tab_3, .key = .{ .codepoint = '3' } },
    .{ .action = .tab_4, .key = .{ .codepoint = '4' } },
    .{ .action = .tab_5, .key = .{ .codepoint = '5' } },
    .{ .action = .tab_6, .key = .{ .codepoint = '6' } },
    .{ .action = .tab_7, .key = .{ .codepoint = '7' } },
    .{ .action = .tab_8, .key = .{ .codepoint = '8' } },
    .{ .action = .tab_9, .key = .{ .codepoint = '9' } },
    .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } },
    .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } },
    .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } },
    .{ .action = .scroll_bottom, .key = .{ .codepoint = 'G' } },
    .{ .action = .page_down, .key = .{ .codepoint = vaxis.Key.page_down } },
    .{ .action = .page_down, .key = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } },
    .{ .action = .page_up, .key = .{ .codepoint = vaxis.Key.page_up } },
    .{ .action = .page_up, .key = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } },
    .{ .action = .select_next, .key = .{ .codepoint = 'j' } },
    .{ .action = .select_next, .key = .{ .codepoint = vaxis.Key.down } },
    .{ .action = .select_prev, .key = .{ .codepoint = 'k' } },
    .{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } },
    .{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
    .{ .action = .help, .key = .{ .codepoint = '?' } },
    .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
};

pub fn defaults() KeyMap {
    return .{ .bindings = &global_default_bindings };
}

/// Display labels for each global Action variant. Used by the help
/// overlay and status-line hint to render human-readable names
/// alongside the resolved key bindings. Parallel in shape to each
/// tab module's own `meta.action_labels`.
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
    .quit = "Quit",
    .refresh = "Refresh",
    .prev_tab = "Previous tab",
    .next_tab = "Next tab",
    .tab_1 = "Tab 1",
    .tab_2 = "Tab 2",
    .tab_3 = "Tab 3",
    .tab_4 = "Tab 4",
    .tab_5 = "Tab 5",
    .tab_6 = "Tab 6",
    .tab_7 = "Tab 7",
    .tab_8 = "Tab 8",
    .tab_9 = "Tab 9",
    .scroll_down = "Half page down",
    .scroll_up = "Half page up",
    .scroll_top = "Scroll to top",
    .scroll_bottom = "Scroll to bottom",
    .page_down = "Page down",
    .page_up = "Page up",
    .select_next = "Select next",
    .select_prev = "Select previous",
    .symbol_input = "Symbol input",
    .help = "Help",
    .reload_portfolio = "Reload portfolio",
});

// ── SRF serialization ────────────────────────────────────────

const special_key_names = [_]struct { name: []const u8, cp: u21 }{
    .{ .name = "tab", .cp = vaxis.Key.tab },
    .{ .name = "enter", .cp = vaxis.Key.enter },
    .{ .name = "escape", .cp = vaxis.Key.escape },
    .{ .name = "space", .cp = vaxis.Key.space },
    .{ .name = "backspace", .cp = vaxis.Key.backspace },
    .{ .name = "insert", .cp = vaxis.Key.insert },
    .{ .name = "delete", .cp = vaxis.Key.delete },
    .{ .name = "left", .cp = vaxis.Key.left },
    .{ .name = "right", .cp = vaxis.Key.right },
    .{ .name = "up", .cp = vaxis.Key.up },
    .{ .name = "down", .cp = vaxis.Key.down },
    .{ .name = "page_up", .cp = vaxis.Key.page_up },
    .{ .name = "page_down", .cp = vaxis.Key.page_down },
    .{ .name = "home", .cp = vaxis.Key.home },
    .{ .name = "end", .cp = vaxis.Key.end },
    .{ .name = "F1", .cp = vaxis.Key.f1 },
    .{ .name = "F2", .cp = vaxis.Key.f2 },
    .{ .name = "F3", .cp = vaxis.Key.f3 },
    .{ .name = "F4", .cp = vaxis.Key.f4 },
    .{ .name = "F5", .cp = vaxis.Key.f5 },
    .{ .name = "F6", .cp = vaxis.Key.f6 },
    .{ .name = "F7", .cp = vaxis.Key.f7 },
    .{ .name = "F8", .cp = vaxis.Key.f8 },
    .{ .name = "F9", .cp = vaxis.Key.f9 },
    .{ .name = "F10", .cp = vaxis.Key.f10 },
    .{ .name = "F11", .cp = vaxis.Key.f11 },
    .{ .name = "F12", .cp = vaxis.Key.f12 },
};

fn codepointToName(cp: u21) ?[]const u8 {
    for (special_key_names) |entry| {
        if (entry.cp == cp) return entry.name;
    }
    return null;
}

fn nameToCodepoint(name: []const u8) ?u21 {
    // Check our table first (case-insensitive for F-keys)
    for (special_key_names) |entry| {
        if (std.ascii.eqlIgnoreCase(entry.name, name)) return entry.cp;
    }
    // Fall back to vaxis name_map (lowercase)
    var lower_buf: [32]u8 = undefined;
    const lower = toLower(name, &lower_buf) orelse return null;
    return vaxis.Key.name_map.get(lower);
}

fn toLower(s: []const u8, buf: []u8) ?[]const u8 {
    if (s.len > buf.len) return null;
    for (s, 0..) |c, i| {
        buf[i] = std.ascii.toLower(c);
    }
    return buf[0..s.len];
}

pub fn formatKeyCombo(combo: KeyCombo, buf: []u8) ?[]const u8 {
    var pos: usize = 0;

    if (combo.mods.ctrl) {
        const prefix = "ctrl+";
        if (pos + prefix.len > buf.len) return null;
        @memcpy(buf[pos..][0..prefix.len], prefix);
        pos += prefix.len;
    }
    if (combo.mods.alt) {
        const prefix = "alt+";
        if (pos + prefix.len > buf.len) return null;
        @memcpy(buf[pos..][0..prefix.len], prefix);
        pos += prefix.len;
    }
    if (combo.mods.shift) {
        const prefix = "shift+";
        if (pos + prefix.len > buf.len) return null;
        @memcpy(buf[pos..][0..prefix.len], prefix);
        pos += prefix.len;
    }

    if (codepointToName(combo.codepoint)) |name| {
        if (pos + name.len > buf.len) return null;
        @memcpy(buf[pos..][0..name.len], name);
        pos += name.len;
    } else if (combo.codepoint >= 0x20 and combo.codepoint < 0x7f) {
        if (pos + 1 > buf.len) return null;
        buf[pos] = @intCast(combo.codepoint);
        pos += 1;
    } else {
        return null;
    }

    return buf[0..pos];
}

fn parseKeyCombo(key_str: []const u8) ?KeyCombo {
    var mods: vaxis.Key.Modifiers = .{};
    var rest = key_str;

    // Parse modifier prefixes
    while (true) {
        if (rest.len > 5 and std.ascii.eqlIgnoreCase(rest[0..5], "ctrl+")) {
            mods.ctrl = true;
            rest = rest[5..];
        } else if (rest.len > 4 and std.ascii.eqlIgnoreCase(rest[0..4], "alt+")) {
            mods.alt = true;
            rest = rest[4..];
        } else if (rest.len > 6 and std.ascii.eqlIgnoreCase(rest[0..6], "shift+")) {
            mods.shift = true;
            rest = rest[6..];
        } else break;
    }

    if (rest.len == 0) return null;

    // Single printable character
    if (rest.len == 1 and rest[0] >= 0x20 and rest[0] < 0x7f) {
        return .{ .codepoint = rest[0], .mods = mods };
    }

    // Named key
    if (nameToCodepoint(rest)) |cp| {
        return .{ .codepoint = cp, .mods = mods };
    }

    return null;
}

/// Print default keybindings in SRF format to stdout.
/// Write the SRF preamble (header line, file-purpose comments,
/// format documentation) to `out`. Used by the `--default-keys`
/// CLI to produce a self-documenting keys.srf file. Caller is
/// responsible for writing the binding records and flushing.
pub fn printDefaultsHeader(out: *std.Io.Writer) !void {
    try out.writeAll("#!srfv1\n");
    try out.writeAll("# zfin TUI keybindings\n");
    try out.writeAll("# This file is the sole source of keybindings when present.\n");
    try out.writeAll("# If this file is removed, built-in defaults are used.\n");
    try out.writeAll("# Regenerate: zfin interactive --default-keys > ~/.config/zfin/keys.srf\n");
    try out.writeAll("#\n");
    try out.writeAll("# Format: action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]\n");
    try out.writeAll("# Modifiers: ctrl+, alt+, shift+ (e.g. ctrl+c)\n");
    try out.writeAll("# Special keys: tab, enter, escape, space, backspace,\n");
    try out.writeAll("#   left, right, up, down, page_up, page_down, home, end,\n");
    try out.writeAll("#   F1-F12, insert, delete\n");
    try out.writeAll("# Multiple lines with the same action = multiple bindings.\n");
    try out.writeAll("#\n");
    try out.writeAll("# scope:: is optional. Omitted (or `scope::global`) = global\n");
    try out.writeAll("#   binding (this section). `scope::<tab>` (e.g. scope::options,\n");
    try out.writeAll("#   scope::history) = tab-local binding; the action name then\n");
    try out.writeAll("#   refers to that tab's local Action enum. Tab-local bindings\n");
    try out.writeAll("#   cannot use a key that's globally bound — zfin will refuse\n");
    try out.writeAll("#   to start if you create that conflict.\n");
}

/// Write a section comment header to `out`. Used to label the
/// global vs. per-tab sections in the generated keys.srf.
pub fn printSectionHeader(out: *std.Io.Writer, name: []const u8) !void {
    try out.writeAll("\n# ── ");
    try out.writeAll(name);
    try out.writeAll(" ──\n");
}

/// Write the global default bindings to `out`, one per line.
pub fn printGlobalBindings(out: *std.Io.Writer) !void {
    for (global_default_bindings) |b| {
        var key_buf: [32]u8 = undefined;
        const key_str = formatKeyCombo(b.key, &key_buf) orelse continue;
        try out.print("action::{s},key::{s}\n", .{ @tagName(b.action), key_str });
    }
}

/// Write a single scoped binding record to `out` in the form:
/// `scope::<scope>,action::<action_name>,key::<key>`.
pub fn printScopedBinding(
    out: *std.Io.Writer,
    scope: []const u8,
    action_name: []const u8,
    key: KeyCombo,
) !void {
    var key_buf: [32]u8 = undefined;
    const key_str = formatKeyCombo(key, &key_buf) orelse return;
    try out.print("scope::{s},action::{s},key::{s}\n", .{ scope, action_name, key_str });
}

/// Print the full default keymap (globals only — this entry point
/// pre-dates the per-tab `default_bindings`). Maintained for
/// backward compatibility with anything calling it directly. New
/// callers should prefer the per-piece helpers above and orchestrate
/// the tab sections themselves; `src/tui.zig`'s `--default-keys`
/// handler does this.
pub fn printDefaults(io: std.Io) !void {
    var buf: [4096]u8 = undefined;
    var writer = std.Io.File.stdout().writer(io, &buf);
    const out = &writer.interface;
    try printDefaultsHeader(out);
    try printSectionHeader(out, "Global");
    try printGlobalBindings(out);
    try out.flush();
}

// ── SRF loading ──────────────────────────────────────────────

fn parseAction(name: []const u8) ?Action {
    return std.meta.stringToEnum(Action, name);
}

/// Errors from `loadFromData` that the caller should surface to
/// the user (stderr) rather than silently fall back to defaults.
pub const LoadError = error{
    /// A user record under `scope::<tab>` binds a key that is also
    /// bound in the global keymap. Globals always win, so the
    /// scoped binding would be dead. The TUI refuses to start.
    TabBindingShadowsGlobal,
};

/// Outcome of `loadFromData`. Either a successfully-built KeyMap,
/// a hard error the caller should surface (stderr + refuse to
/// start), or a soft `null` result (file unparseable) that means
/// "fall back to defaults silently" — the existing behavior.
///
/// Successful results may include `warnings` describing per-record
/// problems (e.g. unknown action name, malformed key string). Each
/// warning is a string allocated in the keymap's arena. The caller
/// is expected to surface these to stderr; they aren't fatal
/// because the rest of the file is usable.
pub const LoadOutcome = union(enum) {
    keymap: KeyMap,
    err: LoadError,
    fallback,
};

/// One record from keys.srf, decoded via `srf.Record.to`.
/// Field names match the SRF field names; `scope` is optional and
/// defaults to null (= global).
const RawRecord = struct {
    action: []const u8,
    key: KeyCombo,
    scope: ?[]const u8 = null,
};

/// Load keybindings from an SRF file. Returns null if the file doesn't exist
/// or can't be parsed. On success, the caller owns the returned KeyMap and
/// must call deinit().
///
/// For richer error reporting (specifically the "tab binding
/// shadows a global" case), use `loadFromFileChecked` which
/// returns a `LoadOutcome`.
pub fn loadFromFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?KeyMap {
    return switch (loadFromFileChecked(io, allocator, path)) {
        .keymap => |km| km,
        .err, .fallback => null,
    };
}

/// Load with detailed outcome. Used by the TUI startup path so
/// `TabBindingShadowsGlobal` can be surfaced as a hard error
/// (stderr + exit) rather than silently falling back to defaults.
pub fn loadFromFileChecked(io: std.Io, allocator: std.mem.Allocator, path: []const u8) LoadOutcome {
    const data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024)) catch return .fallback;
    defer allocator.free(data);
    return loadFromDataChecked(allocator, data);
}

pub fn loadFromData(allocator: std.mem.Allocator, data: []const u8) ?KeyMap {
    return switch (loadFromDataChecked(allocator, data)) {
        .keymap => |km| km,
        .err, .fallback => null,
    };
}

pub fn loadFromDataChecked(allocator: std.mem.Allocator, data: []const u8) LoadOutcome {
    var reader = std.Io.Reader.fixed(data);
    // `.{}` here defaults to `.parse_allocator = .parse_arena`, which
    // is the lifetime model this function depends on: srf copies every
    // parsed string into its internal arena, and we transfer ownership
    // of that arena to the returned `KeyMap` below (rather than calling
    // `ri.deinit()`). All other srf.iterator call sites in zfin use
    // `.parse_allocator = .none` so strings slice into the input
    // buffer; this is the one exception.
    var ri = srf.iterator(&reader, allocator, .{}) catch return .fallback;
    // Don't deinit `ri` until the end — its arena owns the
    // string slices we'll borrow into the returned KeyMap. We
    // transfer ownership to the KeyMap's arena instead.
    //
    // Move ri.arena into our own KeyMap so it outlives this
    // call. The `RecordIterator` holds the arena by pointer; we
    // claim it directly and skip `ri.deinit()`.
    const arena = ri.arena;
    errdefer {
        arena.deinit();
        allocator.destroy(arena);
    }
    const aa = arena.allocator();

    var globals = std.ArrayList(Binding).empty;
    var scopes = std.ArrayList(ScopeBuilder).empty;
    var warnings = std.ArrayList([]const u8).empty;

    var idx: usize = 0;
    while (ri.next() catch return .fallback) |fields| : (idx += 1) {
        const raw = fields.to(RawRecord, .{}) catch |err| {
            // Per-record parse failure (missing field, bad key
            // string, unknown action). Don't drop the whole file —
            // skip the record and warn the user. Record index is
            // 0-based; the user-facing message uses 1-based.
            const msg = std.fmt.allocPrint(
                aa,
                "keys.srf: record {d}: {s}; binding skipped",
                .{ idx + 1, @errorName(err) },
            ) catch return .fallback;
            warnings.append(aa, msg) catch return .fallback;
            continue;
        };

        const is_global = raw.scope == null or std.mem.eql(u8, raw.scope.?, "global");
        if (is_global) {
            const a = parseAction(raw.action) orelse {
                const msg = std.fmt.allocPrint(
                    aa,
                    "keys.srf: record {d}: unknown global action `{s}`; binding skipped",
                    .{ idx + 1, raw.action },
                ) catch return .fallback;
                warnings.append(aa, msg) catch return .fallback;
                continue;
            };
            globals.append(aa, .{ .action = a, .key = raw.key }) catch return .fallback;
        } else {
            // Find or create the scope bucket. Action-name validation
            // against the tab's local `Action` enum happens in the
            // dispatcher at use-time — keybinds.zig doesn't know
            // about tab modules.
            const scope_name = raw.scope.?;
            var bucket: ?*ScopeBuilder = null;
            for (scopes.items) |*sb| {
                if (std.mem.eql(u8, sb.scope, scope_name)) {
                    bucket = sb;
                    break;
                }
            }
            if (bucket == null) {
                scopes.append(aa, .{
                    .scope = scope_name,
                    .bindings = std.ArrayList(ScopedBinding).empty,
                }) catch return .fallback;
                bucket = &scopes.items[scopes.items.len - 1];
            }
            bucket.?.bindings.append(aa, .{
                .action_name = raw.action,
                .key = raw.key,
            }) catch return .fallback;
        }
    }

    // Conflict check: any tab-scoped binding whose key collides
    // with the (post-load) global keymap = hard error. Caller will
    // print the conflict to stderr + refuse to start the TUI.
    for (scopes.items) |sb| {
        for (sb.bindings.items) |b| {
            for (globals.items) |gb| {
                if (b.key.codepoint == gb.key.codepoint and
                    std.meta.eql(b.key.mods, gb.key.mods))
                {
                    arena.deinit();
                    allocator.destroy(arena);
                    return .{ .err = error.TabBindingShadowsGlobal };
                }
            }
        }
    }

    // Materialize tab overrides into owned slices.
    var tab_overrides = std.ArrayList(TabOverrides).empty;
    for (scopes.items) |*sb| {
        const slice = sb.bindings.toOwnedSlice(aa) catch return .fallback;
        tab_overrides.append(aa, .{
            .scope = sb.scope,
            .bindings = slice,
        }) catch return .fallback;
    }

    return .{ .keymap = .{
        .bindings = globals.toOwnedSlice(aa) catch return .fallback,
        .tab_overrides = tab_overrides.toOwnedSlice(aa) catch return .fallback,
        .warnings = warnings.toOwnedSlice(aa) catch return .fallback,
        .arena = arena,
    } };
}

/// Internal scratch type used by `loadFromDataChecked` to bucket
/// scoped bindings during parsing.
const ScopeBuilder = struct {
    scope: []const u8,
    bindings: std.ArrayList(ScopedBinding),
};

// ── Tests ────────────────────────────────────────────────────

test "parseKeyCombo single char" {
    const combo = parseKeyCombo("q").?;
    try std.testing.expectEqual(@as(u21, 'q'), combo.codepoint);
    try std.testing.expect(!combo.mods.ctrl);
}

test "parseKeyCombo ctrl modifier" {
    const combo = parseKeyCombo("ctrl+c").?;
    try std.testing.expectEqual(@as(u21, 'c'), combo.codepoint);
    try std.testing.expect(combo.mods.ctrl);
}

test "parseKeyCombo special key" {
    const combo = parseKeyCombo("F5").?;
    try std.testing.expectEqual(vaxis.Key.f5, combo.codepoint);
}

test "parseKeyCombo named key" {
    const combo = parseKeyCombo("tab").?;
    try std.testing.expectEqual(vaxis.Key.tab, combo.codepoint);
}

test "formatKeyCombo roundtrip" {
    var buf: [32]u8 = undefined;
    const combo = KeyCombo{ .codepoint = 'c', .mods = .{ .ctrl = true } };
    const str = formatKeyCombo(combo, &buf).?;
    try std.testing.expectEqualStrings("ctrl+c", str);
    const parsed = parseKeyCombo(str).?;
    try std.testing.expectEqual(combo.codepoint, parsed.codepoint);
    try std.testing.expect(parsed.mods.ctrl);
}

test "parseAction" {
    try std.testing.expectEqual(Action.quit, parseAction("quit").?);
    try std.testing.expectEqual(Action.refresh, parseAction("refresh").?);
    try std.testing.expect(parseAction("nonexistent") == null);
}

test "loadFromData basic" {
    const data =
        \\#!srfv1
        \\action::quit,key::q
        \\action::quit,key::ctrl+c
        \\action::refresh,key::F5
    ;
    var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed;
    defer km.deinit();
    try std.testing.expectEqual(@as(usize, 3), km.bindings.len);
    try std.testing.expectEqual(Action.quit, km.bindings[0].action);
    try std.testing.expectEqual(Action.refresh, km.bindings[2].action);
    try std.testing.expectEqual(@as(usize, 0), km.tab_overrides.len);
}

test "loadFromData scoped binding" {
    const data =
        \\#!srfv1
        \\action::quit,key::q
        \\scope::options,action::collapse_all_calls,key::C
    ;
    var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed;
    defer km.deinit();
    try std.testing.expectEqual(@as(usize, 1), km.bindings.len);
    try std.testing.expectEqual(@as(usize, 1), km.tab_overrides.len);
    try std.testing.expectEqualStrings("options", km.tab_overrides[0].scope);
    try std.testing.expectEqual(@as(usize, 1), km.tab_overrides[0].bindings.len);
    try std.testing.expectEqualStrings("collapse_all_calls", km.tab_overrides[0].bindings[0].action_name);
    try std.testing.expectEqual(@as(u21, 'C'), km.tab_overrides[0].bindings[0].key.codepoint);
}

test "loadFromData scope::global is treated as global" {
    const data =
        \\#!srfv1
        \\scope::global,action::quit,key::q
    ;
    var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed;
    defer km.deinit();
    try std.testing.expectEqual(@as(usize, 1), km.bindings.len);
    try std.testing.expectEqual(Action.quit, km.bindings[0].action);
    try std.testing.expectEqual(@as(usize, 0), km.tab_overrides.len);
}

test "loadFromDataChecked rejects tab binding shadowing global" {
    // Both `q` (global quit) and a scoped binding for the same key.
    const data =
        \\#!srfv1
        \\action::quit,key::q
        \\scope::options,action::collapse_all_calls,key::q
    ;
    const outcome = loadFromDataChecked(std.testing.allocator, data);
    switch (outcome) {
        .err => |e| try std.testing.expectEqual(LoadError.TabBindingShadowsGlobal, e),
        else => return error.ExpectedConflictError,
    }
}

test "loadFromData scoped bindings group by scope" {
    const data =
        \\#!srfv1
        \\scope::options,action::collapse_all_calls,key::C
        \\scope::options,action::collapse_all_puts,key::P
        \\scope::history,action::metric_next,key::M
    ;
    var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed;
    defer km.deinit();
    try std.testing.expectEqual(@as(usize, 2), km.tab_overrides.len);
    // Order matches first-occurrence in input.
    try std.testing.expectEqualStrings("options", km.tab_overrides[0].scope);
    try std.testing.expectEqual(@as(usize, 2), km.tab_overrides[0].bindings.len);
    try std.testing.expectEqualStrings("history", km.tab_overrides[1].scope);
    try std.testing.expectEqual(@as(usize, 1), km.tab_overrides[1].bindings.len);
}

test "loadFromData unknown action emits warning" {
    const data =
        \\#!srfv1
        \\action::quit,key::q
        \\action::not_a_real_action,key::z
    ;
    var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed;
    defer km.deinit();
    try std.testing.expectEqual(@as(usize, 1), km.bindings.len);
    try std.testing.expectEqual(@as(usize, 1), km.warnings.len);
    try std.testing.expect(std.mem.indexOf(u8, km.warnings[0], "not_a_real_action") != null);
}

// NOTE: there's no `loadFromData malformed key emits warning` test
// here. That path goes through srf's `coerce` → `KeyCombo.srfParse`,
// and srf logs at `log.err` when our srfParse returns an error —
// which the Zig 0.16 test runner treats as a test failure even when
// the test itself passes its asserts. The malformed-key parsing
// path is covered directly by the `parseKeyCombo` tests above.

test "tabOverridesFor lookup" {
    const data =
        \\#!srfv1
        \\scope::options,action::collapse_all_calls,key::C
    ;
    var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed;
    defer km.deinit();
    try std.testing.expect(km.tabOverridesFor("options") != null);
    try std.testing.expect(km.tabOverridesFor("history") == null);
}

test "defaults returns valid keymap" {
    const km = defaults();
    try std.testing.expect(km.bindings.len > 0);
    // Verify quit is in there
    var found_quit = false;
    for (km.bindings) |b| {
        if (b.action == .quit) found_quit = true;
    }
    try std.testing.expect(found_quit);
}

test "action_labels: every Action variant has a non-empty label" {
    inline for (std.meta.fields(Action)) |f| {
        const action: Action = @enumFromInt(f.value);
        const label = action_labels.get(action);
        try std.testing.expect(label.len > 0);
    }
}

test "printScopedBinding writes scope, action, key in expected order" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    const key: KeyCombo = .{ .codepoint = 'a', .mods = .{} };
    try printScopedBinding(&aw.writer, "history", "compare_select", key);
    try std.testing.expectEqualStrings(
        "scope::history,action::compare_select,key::a\n",
        aw.written(),
    );
}

test "printScopedBinding renders modifier keys" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    const key: KeyCombo = .{ .codepoint = 'c', .mods = .{ .ctrl = true } };
    try printScopedBinding(&aw.writer, "options", "filter_3", key);
    try std.testing.expectEqualStrings(
        "scope::options,action::filter_3,key::ctrl+c\n",
        aw.written(),
    );
}

test "printGlobalBindings emits at least quit and refresh" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try printGlobalBindings(&aw.writer);
    const out = aw.written();
    try std.testing.expect(std.mem.indexOf(u8, out, "action::quit,key::q") != null);
    try std.testing.expect(std.mem.indexOf(u8, out, "action::refresh,key::r") != null);
    // Globals never carry a scope:: prefix.
    try std.testing.expect(std.mem.indexOf(u8, out, "scope::") == null);
}

test "printSectionHeader writes a comment header line" {
    var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
    defer aw.deinit();
    try printSectionHeader(&aw.writer, "Tab: portfolio");
    try std.testing.expectEqualStrings("\n# ── Tab: portfolio ──\n", aw.written());
}
