const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const Money = @import("Money.zig");
const cli = @import("commands/common.zig");
const stderr = @import("stderr.zig");
const keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig");
const framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig");
const chart = @import("tui/chart.zig");
const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig");

/// Single source of truth for tab modules. Each entry is the
/// imported tab module; the field name is the tab's tag. The
/// `Tab` enum, `TabStates`, `tab_labels`, and the `tabs` slice
/// are all derived from this registry at comptime — adding a
/// new tab is a single edit (append a field here, declare the
/// tab's `tab` namespace + `State`, and everything else flows
/// from `pub const label` on the tab module).
const tab_modules = .{
    .portfolio = @import("tui/portfolio_tab.zig"),
    .analysis = @import("tui/analysis_tab.zig"),
    .review = @import("tui/review_tab.zig"),
    .projections = @import("tui/projections_tab.zig"),
    .history = @import("tui/history_tab.zig"),
    .quote = @import("tui/quote_tab.zig"),
    .performance = @import("tui/performance_tab.zig"),
    .earnings = @import("tui/earnings_tab.zig"),
    .options = @import("tui/options_tab.zig"),
};

/// Comptime-generated table of single-character grapheme slices with static lifetime.
/// This avoids dangling pointers from stack-allocated temporaries in draw functions.
const ascii_g = blk: {
    var table: [128][]const u8 = undefined;
    for (0..128) |i| {
        const ch: [1]u8 = .{@as(u8, @intCast(i))};
        table[i] = &ch;
    }
    break :blk table;
};

/// Build a fixed-display-width column header label with optional sort indicator.
/// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total
/// display width stays constant. Indicator always appears on the left side.
/// `left` controls text alignment (left-aligned vs right-aligned).
pub fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 {
    const ind = indicator orelse {
        // No indicator: plain padded label
        if (left) {
            @memset(buf[0..col_width], ' ');
            @memcpy(buf[0..name.len], name);
            return buf[0..col_width];
        } else {
            @memset(buf[0..col_width], ' ');
            const offset = col_width - name.len;
            @memcpy(buf[offset..][0..name.len], name);
            return buf[0..col_width];
        }
    };
    // Indicator always on the left, replacing one padding space.
    // total display cols = col_width, byte length = col_width - 1 + ind.len
    const total_bytes = col_width - 1 + ind.len;
    if (total_bytes > buf.len) return name;
    if (left) {
        // "▲Name   " — indicator, text, then spaces
        @memcpy(buf[0..ind.len], ind);
        @memcpy(buf[ind.len..][0..name.len], name);
        const content_len = ind.len + name.len;
        if (content_len < total_bytes) @memset(buf[content_len..total_bytes], ' ');
    } else {
        // "  ▲Name" — spaces, indicator, then text
        const pad = col_width - name.len - 1;
        @memset(buf[0..pad], ' ');
        @memcpy(buf[pad..][0..ind.len], ind);
        @memcpy(buf[pad + ind.len ..][0..name.len], name);
    }
    return buf[0..total_bytes];
}

pub fn glyph(ch: u8) []const u8 {
    if (ch < 128) return ascii_g[ch];
    return " ";
}

/// Tab enum derived from `tab_modules` registry. Each variant
/// matches a registry field name; variant order = registry order
/// = tab-bar display order. Adding a tab requires no edit here —
/// just append to `tab_modules` and the variant appears.
pub const Tab = blk: {
    const reg_fields = std.meta.fields(@TypeOf(tab_modules));
    var names: [reg_fields.len][]const u8 = undefined;
    var values: [reg_fields.len]u8 = undefined;
    for (reg_fields, 0..) |f, i| {
        names[i] = f.name;
        values[i] = @intCast(i);
    }
    break :blk @Enum(u8, .exhaustive, &names, &values);
};

/// Comptime lookup table of tab-bar display labels, indexed by
/// `@intFromEnum(tab)`. Each entry is `" {N}:{label} "` composed
/// from the 1-indexed registry position + the tab module's
/// `pub const label`. The format (number prefix, padding) is
/// framework policy; the bare name is owned by the tab module.
const tab_labels = blk: {
    const reg_fields = std.meta.fields(@TypeOf(tab_modules));
    var arr: [reg_fields.len][]const u8 = undefined;
    for (reg_fields, 0..) |f, i| {
        const Module = @field(tab_modules, f.name);
        arr[i] = std.fmt.comptimePrint(" {d}:{s} ", .{ i + 1, Module.meta.label });
    }
    break :blk arr;
};

fn tabLabel(t: Tab) []const u8 {
    return tab_labels[@intFromEnum(t)];
}

/// All tab variants in registry order. Used for tab-bar iteration
/// (rendering, hit-testing, next/prev navigation). Equivalent to
/// `std.enums.values(Tab)`; aliased for brevity at call sites.
const tabs: []const Tab = std.enums.values(Tab);

/// Truly global UI modes layered over any tab. Tab-internal
/// modal sub-states (e.g. portfolio's account picker,
/// projections' as-of date input) live in the tab's own
/// `State.modal`, not here.
pub const InputMode = enum {
    /// No global mode active.
    normal,
    /// Symbol-input prompt (any tab can trigger via the
    /// `symbol_input` action).
    symbol_input,
    /// Help overlay.
    help,
};

pub const StyledLine = struct {
    text: []const u8,
    style: vaxis.Style,
    // Optional per-character style override ranges (for mixed-color lines)
    alt_text: ?[]const u8 = null, // text for the gain/loss column
    alt_style: ?vaxis.Style = null,
    alt_start: usize = 0,
    alt_end: usize = 0,
    // Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts).
    // When set, each element is a grapheme string for one column position.
    graphemes: ?[]const []const u8 = null,
    // Optional per-cell style array (same length as graphemes). Enables color gradients.
    cell_styles: ?[]const vaxis.Style = null,
    // Optional list of column-range style spans. Used by lines that need
    // more than one secondary style (e.g. the review tab's wide table
    // where every numeric cell may have its own intent color). Spans
    // are applied in order; the LAST matching span wins for any cell.
    // Ranges are display-column-based (`[start, end)`), matching
    // `alt_start`/`alt_end`. When set, `alt_*` is ignored.
    spans: ?[]const StyleSpan = null,
};

/// Display-column range with a style override. See `StyledLine.spans`.
pub const StyleSpan = struct {
    start: usize,
    end: usize,
    style: vaxis.Style,
};

/// Pre-resolved row in the help overlay: a key string (possibly
/// comma-joined for multiple bindings) plus its label. Used by
/// `buildHelpLines` so the renderer is a pure function over already-
/// resolved data and doesn't need access to the keymap or tab modules.
pub const HelpRow = struct {
    keys: []const u8,
    label: []const u8,
};

/// One `key label` fragment of the dynamic status-hint line. Used by
/// `formatStatusHint` to compose the full hint as ` | `-joined fragments.
pub const StatusHintFragment = struct {
    key: []const u8,
    label: []const u8,
};

/// Format the dynamic default status hint from pre-resolved key /
/// label fragments. Each fragment renders as `key label`; fragments
/// are joined with ` | `. Pure function — no App access.
pub fn formatStatusHint(
    arena: std.mem.Allocator,
    fragments: []const StatusHintFragment,
) ![]const u8 {
    if (fragments.len == 0) return "";
    var pieces: std.ArrayListUnmanaged([]const u8) = .empty;
    for (fragments) |f| {
        try pieces.append(arena, try std.fmt.allocPrint(arena, "{s} {s}", .{ f.key, f.label }));
    }
    return std.mem.join(arena, " | ", pieces.items);
}

/// Pre-resolved data passed to `buildHelpLines`. Comprises the
/// global section's rows, the active tab section's rows, and the
/// active tab's display name (without the registry-position prefix).
pub const HelpData = struct {
    globals: []const HelpRow,
    tab_rows: []const HelpRow,
    active_tab_name: []const u8,
};

/// Render the help overlay's styled lines from pre-resolved data.
/// Pure function — no App access, no keymap lookup. Easy to test
/// with fixture rows.
pub fn buildHelpLines(
    arena: std.mem.Allocator,
    th: theme.Theme,
    data: HelpData,
) ![]const StyledLine {
    var lines: std.ArrayListUnmanaged(StyledLine) = .empty;

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  zfin TUI -- Keybindings", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    try lines.append(arena, .{ .text = "  Global", .style = th.headerStyle() });
    for (data.globals) |row| {
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "    {s:<20}  {s}", .{ row.keys, row.label }),
            .style = th.contentStyle(),
        });
    }

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  Active Tab: {s}", .{data.active_tab_name}),
        .style = th.headerStyle(),
    });
    if (data.tab_rows.len == 0) {
        try lines.append(arena, .{
            .text = "    (no tab-local actions)",
            .style = th.mutedStyle(),
        });
    } else {
        for (data.tab_rows) |row| {
            try lines.append(arena, .{
                .text = try std.fmt.allocPrint(arena, "    {s:<20}  {s}", .{ row.keys, row.label }),
                .style = th.contentStyle(),
            });
        }
    }

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "  Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Press any key to close.", .style = th.dimStyle() });

    return lines.toOwnedSlice(arena);
}

/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
///
/// Per-tab private state aggregator, derived at comptime from the
/// `tab_modules` registry. One field per registered tab; the field
/// name matches the registry tag (and the `Tab` enum variant), and
/// the type is that tab module's `State`.
///
/// Adding a tab is one edit: append it to `tab_modules` (and add the
/// matching `Tab` enum variant + `label`). `TabStates` updates
/// automatically.
pub const TabStates = blk: {
    const reg_fields = std.meta.fields(@TypeOf(tab_modules));
    var names: [reg_fields.len][]const u8 = undefined;
    var types: [reg_fields.len]type = undefined;
    var attrs: [reg_fields.len]std.builtin.Type.StructField.Attributes = undefined;
    for (reg_fields, 0..) |f, i| {
        const Module = @field(tab_modules, f.name);
        const default: Module.State = .{};
        names[i] = f.name;
        types[i] = Module.State;
        attrs[i] = .{ .default_value_ptr = &default };
    }
    break :blk @Struct(.auto, null, &names, &types, &attrs);
};

comptime {
    for (std.meta.fields(@TypeOf(tab_modules))) |field| {
        const Module = @field(tab_modules, field.name);
        tab_framework.validateTabModule(Module);
    }
}

// Comptime check: tabs must not bind keys that are already bound
// in the global keymap. Globals always win (the dispatcher reaches
// `dispatchTabLocalKey` only when `keymap.matchAction` returns
// null), so a tab-local binding for a globally-bound key is dead
// code. Reject it loudly at compile time.
//
// This lives in `tui.zig` rather than `tab_framework.zig` to keep
// the framework decoupled from the global keymap; here both are
// already in scope.
comptime {
    @setEvalBranchQuota(20000);
    for (std.meta.fields(@TypeOf(tab_modules))) |field| {
        const Module = @field(tab_modules, field.name);
        for (Module.meta.default_bindings) |binding| {
            for (keybinds.global_default_bindings) |global| {
                if (binding.key.codepoint == global.key.codepoint and
                    std.meta.eql(binding.key.mods, global.key.mods))
                {
                    @compileError("Tab `" ++ field.name ++ "` binds a key in `default_bindings` " ++
                        "that is already bound in the global keymap (`keybinds.zig`). " ++
                        "Tab-local bindings cannot override global keys; pick a different key " ++
                        "or remove the global binding. Conflicting tab action: ." ++
                        @tagName(binding.action) ++ "; conflicting global action: ." ++
                        @tagName(global.action));
                }
            }
        }
    }
}

/// Per-symbol fetched data. Owned by `App` and accessed as
/// `app.symbol_data.*`. Populated by whichever tab fetches first
/// (typically the perf or quote tab); consumed by every tab that
/// renders symbol-bound information (quote, perf, options, earnings).
///
/// Distinct from "tab-private state" in `app.states` because a
/// single tab doesn't own this data — it's a shared cache scoped
/// to "the current symbol." Cleared in `resetSymbolData` whenever
/// the user changes symbols.
///
/// **Cross-tab mutation note.** Today consumers re-read these
/// fields on every render frame, so mutations don't need
/// notification. If a future tab needs to react to a mutation
/// (e.g. recompute a derived value when `candles` changes rather
/// than re-derive on every render), the right escape valve is a
/// new framework lifecycle hook (e.g. `onSymbolDataChange`) that
/// tabs opt into via `@hasDecl`. We're not adding it speculatively
/// — the read-on-render pattern is sufficient for current
/// consumers.
pub const SymbolData = struct {
    /// Daily OHLCV candles for the active symbol, oldest-first.
    /// Owned by SymbolData; freed via `deinit` or `clear`.
    candles: ?[]zfin.Candle = null,
    /// Unix-epoch seconds for the candle fetch — drives the
    /// "data Xs ago" header readout.
    candle_timestamp: i64 = 0,
    /// Dividend events. Owned by SymbolData; freed via `deinit`
    /// or `clear`.
    dividends: ?[]zfin.Dividend = null,
    /// Trailing risk metrics (volatility, sharpe, max drawdown)
    /// computed from the candle series.
    risk_metrics: ?zfin.risk.TrailingRisk = null,
    /// Trailing returns at the candle endpoint date (price-only
    /// vs total-return — total requires dividend data).
    trailing_price: ?zfin.performance.TrailingReturns = null,
    trailing_total: ?zfin.performance.TrailingReturns = null,
    /// Trailing returns at the most recent month-end (for
    /// Morningstar-style reporting).
    trailing_me_price: ?zfin.performance.TrailingReturns = null,
    trailing_me_total: ?zfin.performance.TrailingReturns = null,
    /// ETF profile (holdings, sectors). Loaded lazily on perf tab
    /// activation; null for non-ETFs. The `etf_loaded` flag means
    /// "we attempted the load" so we don't retry every activation.
    etf_profile: ?zfin.EtfProfile = null,
    etf_loaded: bool = false,

    /// Free all owned slices. Idempotent — safe to call after
    /// partial-load failure or repeated.
    pub fn deinit(self: *SymbolData, allocator: std.mem.Allocator) void {
        self.clear(allocator);
    }

    /// Free all owned slices and reset to defaults. Used on symbol
    /// change so the next fetch starts from a clean slate.
    pub fn clear(self: *SymbolData, allocator: std.mem.Allocator) void {
        if (self.candles) |c| allocator.free(c);
        if (self.dividends) |d| zfin.Dividend.freeSlice(allocator, d);
        // Use EtfProfile.deinit so we free every owned field —
        // including `symbol` and `name`, which an earlier
        // hand-rolled inline cleanup here was silently leaking.
        if (self.etf_profile) |profile| profile.deinit(allocator);
        self.* = .{};
    }

    // ── Derived projections of `candles` ──────────────────────
    //
    // These are functions, not fields, so they can't drift from
    // the underlying `candles` slice. Renderers call them per-frame;
    // the cost is a single deref + (for first/last) a slice index.

    /// Number of cached candles. Zero when candles haven't loaded.
    pub fn candleCount(self: *const SymbolData) usize {
        return if (self.candles) |c| c.len else 0;
    }

    /// First (oldest) candle date, or null if no candles.
    pub fn candleFirstDate(self: *const SymbolData) ?zfin.Date {
        const c = self.candles orelse return null;
        if (c.len == 0) return null;
        return c[0].date;
    }

    /// Last (newest) candle date, or null if no candles.
    pub fn candleLastDate(self: *const SymbolData) ?zfin.Date {
        const c = self.candles orelse return null;
        if (c.len == 0) return null;
        return c[c.len - 1].date;
    }
};

/// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget`
/// interface via `widget()`, which wires `typeErasedEventHandler` and
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
/// top-level widget; vaxis drives the event loop, calling back into App
/// for key/mouse/init events and for each frame's draw.
///
/// Owns all application state: the active tab, cached data for each tab,
/// navigation/scroll positions, input mode, and a reference to the
/// `DataService` for fetching financial data. Tab-specific rendering and
/// data loading are delegated to the `tui/*_tab.zig` modules.
pub const App = struct {
    allocator: std.mem.Allocator,
    io: std.Io,
    /// Per-tab private state. See `TabStates` above. Each tab
    /// owns its UI state under `app.states.<tab>` — the field
    /// name matches the `tab_modules` registry tag.
    states: TabStates = .{},
    /// Per-symbol shared data (candles, dividends, trailing returns,
    /// ETF profile). See `SymbolData` above. Cleared in
    /// `resetSymbolData` on symbol change.
    symbol_data: SymbolData = .{},
    /// Per-portfolio shared data (loaded portfolio file, summary,
    /// account map, watchlist prices, historical snapshots). See
    /// `PortfolioData` above. Reloaded by
    /// `tab_modules.portfolio.reloadPortfolioFile` on file changes.
    /// Constructed at App init via `PortfolioData.init(allocator)`;
    /// no default because the arena needs a backing allocator.
    portfolio: PortfolioData,
    /// Captured at App init and refreshed at tab change. Using a cached
    /// date (rather than calling the clock on every render) keeps render
    /// deterministic within a single frame and avoids threading `io`
    /// through pure date-consuming helpers like `positions()`.
    today: zfin.Date,
    config: zfin.Config,
    svc: *zfin.DataService,
    keymap: keybinds.KeyMap,
    theme: theme.Theme,
    active_tab: Tab = .portfolio,
    symbol: []const u8 = "",
    // SAFETY: paired with `symbol` slice; only the bytes pointed
    // to by `symbol.ptr[0..symbol.len]` are ever read. The tail
    // is unobservable.
    symbol_buf: [16]u8 = undefined,
    symbol_owned: bool = false,
    /// Symbol the overlay popup is currently showing details for.
    /// Empty when the overlay is closed. Bytes live in
    /// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`.
    overlay_symbol: []const u8 = "",
    // SAFETY: paired with `overlay_symbol`; only the prefix
    // overlay_symbol_buf[0..overlay_symbol.len] is observable.
    overlay_symbol_buf: [16]u8 = undefined,
    scroll_offset: usize = 0,
    visible_height: u16 = 24, // updated each draw
    /// Monotonic counter incremented each draw frame. Passed to the
    /// active tab's `tick` hook so polling-based async work (future
    /// observation engine async dispatch, etc.) has a wall-clock-
    /// independent tick to react to.
    frame: u64 = 0,
    /// True while a vxfw Tick command is scheduled and not yet
    /// delivered. Prevents stacking duplicate timers (each event
    /// would otherwise arm another). Whether polling is WANTED is
    /// derived fresh from the active tab's optional
    /// `wantsPollTick` hook — see `typeErasedEventHandler`.
    poll_tick_armed: bool = false,

    has_explicit_symbol: bool = false, // true if -s was used

    watchlist: ?[][]const u8 = null,
    watchlist_path: ?[]const u8 = null,
    // SAFETY: paired with `status_len`; only the prefix
    // `status_msg[0..status_len]` is ever read.
    status_msg: [256]u8 = undefined,
    status_len: usize = 0,

    /// True between the refresh keypress and the deferred refresh
    /// work completing. While set, the status bar shows the
    /// in-progress indicator; the poll tick is armed so the
    /// (blocking) refresh runs one frame later, letting the
    /// indicator paint first (vxfw draws after the handler returns).
    refresh_pending: bool = false,
    /// Wall-clock seconds when the last manual refresh completed
    /// (0 = never). Drives the "refreshed Xs ago" status readout.
    last_refresh_s: i64 = 0,

    // Input mode state
    mode: InputMode = .normal,
    // SAFETY: paired with `input_len`; only the prefix
    // `input_buf[0..input_len]` is ever read.
    input_buf: [16]u8 = undefined,
    input_len: usize = 0,

    // Portfolio tab state lives in `self.states.portfolio` (see TabStates).

    // Account picker / search state lives in
    // `self.states.portfolio` — see portfolio_tab.zig. The picker
    // is fully tab-internal: opened/closed via
    // `state.modal` and routed through portfolio's own
    // `handleKey` / `handleMouse` / `drawContent` /
    // `statusOverride` hooks. App.Mode does NOT carry picker
    // variants.

    // History tab state lives in `self.states.history` (see TabStates).
    // Projections tab state lives in `self.states.projections`.

    // Mouse wheel debounce for cursor-based tabs (portfolio, options).
    // Terminals often send multiple wheel events per physical tick.
    last_wheel_ns: i128 = 0,

    /// Global chart-rendering config (mode, max dimensions). Driven
    /// by the `--chart` CLI flag at startup; not per-tab. Consumed by
    /// any tab that renders pixel charts (quote, projections, future
    /// forecast-evaluation views).
    chart_config: chart.ChartConfig = .{},
    vx_app: ?*vaxis.vxfw.App = null, // set during run(), for Kitty graphics access

    pub fn widget(self: *App) vaxis.vxfw.Widget {
        return .{
            .userdata = self,
            .eventHandler = typeErasedEventHandler,
            .drawFn = typeErasedDrawFn,
        };
    }

    fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
        const self: *App = @ptrCast(@alignCast(ptr));
        // Poll-driven redraw support: while the ACTIVE tab reports
        // async work in flight (via its optional `wantsPollTick`
        // hook), keep a one-shot vxfw Tick timer armed. The timer
        // fires a `.tick` event, which requests a redraw; the draw
        // pass runs the active tab's `tick` hook, which polls the
        // async work. The decision is derived fresh after every
        // event — no stored flag to choreograph across
        // activate/deactivate/completion, and tab switches are
        // picked up automatically because dispatch targets the
        // active tab. 100ms cadence — fast enough to feel
        // responsive, slow enough to be invisible in CPU terms.
        defer if (!self.poll_tick_armed and (self.refresh_pending or self.dispatchBool("wantsPollTick", .{}))) {
            if (ctx.tick(100, self.widget())) {
                self.poll_tick_armed = true;
            } else |err| {
                // OOM appending the Tick command. Leave
                // poll_tick_armed false so the next event retries
                // the arm; worst case the poll degrades to
                // event-driven (pre-timer behavior), not a
                // permanently stuck spinner.
                std.log.scoped(.tui).warn("poll tick arm failed: {t}", .{err});
            }
        };
        switch (event) {
            .key_press => |key| {
                // Tab-level pre-empt. The active tab gets first
                // crack at every key (when it declares the hook).
                // Used for tab-internal modals (account picker on
                // portfolio, date input on projections, etc) that
                // need to swallow keys before global keymap
                // matching runs — otherwise typing `r` while a
                // modal is open would refresh, which would be
                // surprising.
                //
                // Tabs that don't declare `handleKey` skip this.
                // Tabs that declare it but aren't currently in a
                // modal sub-state should return `false` so
                // dispatch falls through to the normal path.
                if (self.dispatchBool("handleKey", .{key})) {
                    return ctx.consumeAndRedraw();
                }

                if (self.mode == .symbol_input) {
                    return self.handleInputKey(ctx, key);
                }
                if (self.mode == .help) {
                    self.mode = .normal;
                    return ctx.consumeAndRedraw();
                }
                return self.handleNormalKey(ctx, key);
            },
            .mouse => |mouse| {
                return self.handleMouse(ctx, mouse);
            },
            .init => {
                self.loadTabData();
            },
            .tick => {
                // Our scheduled timer fired. Mark it un-armed (the
                // defer above re-arms if there's more work).
                self.poll_tick_armed = false;
                if (self.refresh_pending) {
                    // Phase 2 of refresh: the indicator painted last
                    // frame; now run the (blocking) refresh.
                    self.refresh_pending = false;
                    self.refreshCurrentTab();
                    // wall-clock required: timestamp the completed
                    // refresh for the "refreshed Xs ago" readout.
                    self.last_refresh_s = std.Io.Timestamp.now(self.io, .real).toSeconds();
                    ctx.redraw = true;
                } else if (self.dispatchBool("wantsPollTick", .{})) {
                    // Poll-driven redraw so the draw pass runs the
                    // active tab's tick hook.
                    ctx.redraw = true;
                }
            },
            else => {},
        }
    }

    fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
        // Active-tab dispatch first. Tabs return `true` to
        // consume; on `false` we fall through to App-level
        // handling (tab-bar clicks, viewport scroll). This gives
        // tab-internal modals (e.g. portfolio's account picker)
        // first crack at every mouse event so they can swallow
        // tab-bar clicks and prevent tab switching while modal.
        //
        // We probe `statusOverride` first to detect "tab is in a
        // modal sub-state" — when it returns non-null, the
        // tab gets the FULL mouse stream (including row 0).
        // Otherwise (normal mode), we apply chrome ownership:
        // row 0 is the tab bar, so non-modal tabs only see
        // content-region events. This prevents
        // `mouse.row + scroll_offset` ambiguity when a tab's
        // handleMouse derives a content row from the raw mouse
        // row — row 0 would otherwise look like
        // `scroll_offset` rows of header, which can mis-read as
        // a header/sort click.
        const tab_is_modal = self.activeTabStatusOverride() != null;
        if (tab_is_modal or mouse.row > 0) {
            if (self.dispatchBool("handleMouse", .{mouse})) {
                return ctx.consumeAndRedraw();
            }
        }
        if (tab_is_modal) {
            // Modal didn't consume (it always should — see
            // contract above), but be defensive: still swallow
            // the event so tab-bar clicks etc don't fire.
            return ctx.consumeAndRedraw();
        }

        switch (mouse.button) {
            .wheel_up => {
                // shift+wheel scrolls the viewport (look-around,
                // no cursor change). Plain wheel routes through
                // `wheelBy` which prefers the active tab's
                // `onCursorMove`/`onWheelMove`. We pick shift
                // (not ctrl) because ctrl+wheel is the
                // near-universal "zoom" gesture in browsers and
                // editors — bending that to "scroll" would
                // confuse muscle memory.
                if (mouse.mods.shift) {
                    self.scrollViewportBy(-3);
                } else {
                    self.wheelBy(-3);
                }
                return ctx.consumeAndRedraw();
            },
            .wheel_down => {
                if (mouse.mods.shift) {
                    self.scrollViewportBy(3);
                } else {
                    self.wheelBy(3);
                }
                return ctx.consumeAndRedraw();
            },
            .left => {
                if (mouse.type != .press) return;
                // Tab bar: click to switch tabs
                if (mouse.row == 0) {
                    var col: i16 = 0;
                    for (tabs) |t| {
                        const lbl_len: i16 = @intCast(tabLabel(t).len);
                        if (mouse.col >= col and mouse.col < col + lbl_len) {
                            if (self.isDisabled(t)) return;
                            self.active_tab = t;
                            self.scroll_offset = 0;
                            self.loadTabData();
                            ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
                            return ctx.consumeAndRedraw();
                        }
                        col += lbl_len;
                    }
                }
                // Content-region clicks already went through
                // `dispatchBool("handleMouse")` above when
                // `mouse.row > 0`. Reaching here means the tab
                // declined to consume — nothing left to do.
            },
            else => {},
        }
    }

    /// Does the active tab declare the named hook? Pure predicate;
    /// no dispatch. Used to gate work that conditionally fires
    /// alongside a hook (e.g. wheel-debounce only for cursor-bearing
    /// tabs in `moveBy`).
    fn activeTabHas(self: *const App, comptime hook_name: []const u8) bool {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                return @hasDecl(Module.tab, hook_name);
            }
        }
        return false;
    }

    /// Dispatch to a `bool`-returning optional hook on the active
    /// tab. Returns the hook's result, or `false` if the active tab
    /// doesn't declare the hook (so callers can treat "not declared"
    /// the same as "declined to consume").
    ///
    /// `args` is a tuple of the trailing arguments after `(state, app)`
    /// — for `handleMouse`, that's `.{mouse}`; for `onCursorMove`,
    /// `.{delta}`. The validator in `tab_framework` already enforces
    /// each hook's full signature at comptime, so a typo'd `hook_name`
    /// or wrong arg shape is caught when the registered tab module is
    /// checked at module-init time.
    ///
    /// `anytype` is justified here: this is generic dispatch over a
    /// closed set of hook signatures whose shapes are independently
    /// validated by `tab_framework.validateTabModule`. The framework
    /// IS the type contract; this dispatcher is the runtime accessor.
    fn dispatchBool(self: *App, comptime hook_name: []const u8, args: anytype) bool {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                if (!@hasDecl(Module.tab, hook_name)) return false;
                const fn_ptr = @field(Module.tab, hook_name);
                const state_ptr = &@field(self.states, field.name);
                return @call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
            }
        }
        return false;
    }

    /// Dispatch to a `void`-returning optional hook on the active
    /// tab. No-op if the active tab doesn't declare the hook.
    /// See `dispatchBool` for the `args` tuple convention and the
    /// rationale for the `anytype` parameter.
    fn dispatchVoid(self: *App, comptime hook_name: []const u8, args: anytype) void {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                if (!@hasDecl(Module.tab, hook_name)) return;
                const fn_ptr = @field(Module.tab, hook_name);
                const state_ptr = &@field(self.states, field.name);
                @call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
                return;
            }
        }
    }

    /// Dispatch to a fallible (`!void`-returning) hook on the
    /// active tab. Errors are swallowed — matches the existing
    /// `tab.activate(...) catch {}` idiom that this dispatcher
    /// replaces. Use this for `activate`/`reload`/etc. where the
    /// caller doesn't have a meaningful recovery path.
    ///
    /// The contract validator already required `activate`/`reload`
    /// to exist on every tab, so the `@hasDecl` guard is mostly
    /// future-proofing for newly-added fallible hooks that aren't
    /// universally required.
    fn dispatchTry(self: *App, comptime hook_name: []const u8, args: anytype) void {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                if (!@hasDecl(Module.tab, hook_name)) return;
                const fn_ptr = @field(Module.tab, hook_name);
                const state_ptr = &@field(self.states, field.name);
                @call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch |err| {
                    // Tab hook failed; log and continue. dispatchTry
                    // is intentionally best-effort — see the doc-comment
                    // above. A failing hook usually means OOM; the next
                    // user action will retry.
                    std.log.debug("tab hook {s} failed: {t}", .{ hook_name, err });
                };
                return;
            }
        }
    }

    /// Broadcast to every tab that declares `hook_name` — not just
    /// the active tab. Tabs that don't declare it are skipped.
    /// Order is `tab_modules` declaration order; callers should not
    /// rely on a particular order across tabs (each tab's hook is
    /// expected to handle the change independently).
    ///
    /// Used for global context changes that every interested tab
    /// needs to react to (e.g. `onSymbolChange` — every tab with
    /// per-symbol cached state opts in to drop it). Distinct from
    /// `dispatchVoid`, which only notifies the active tab.
    ///
    /// Only meaningful for void-returning hooks; bool-returning
    /// hooks have ambiguous broadcast semantics ("all consumed"?
    /// "any consumed"?) so the framework declines to define them.
    /// See `dispatchBool` for the `args` tuple convention and the
    /// rationale for `anytype`.
    pub fn broadcast(self: *App, comptime hook_name: []const u8, args: anytype) void {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            const Module = @field(tab_modules, field.name);
            if (@hasDecl(Module.tab, hook_name)) {
                const fn_ptr = @field(Module.tab, hook_name);
                const state_ptr = &@field(self.states, field.name);
                @call(.auto, fn_ptr, .{ state_ptr, self } ++ args);
            }
        }
    }

    /// Call a `bool`-returning App-level predicate hook on the
    /// specified tab (not necessarily the active tab). Returns
    /// `false` if the tab doesn't declare the hook. Distinct from
    /// `dispatchBool` because the hook signature is `fn(*App)bool`
    /// — App-only, no tab State arg. Used for predicates like
    /// `isDisabled` that depend on App-level context (whether a
    /// portfolio is loaded, etc.) rather than tab-private state.
    fn appPredicate(self: *App, target: Tab, comptime hook_name: []const u8) bool {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(target))) {
                const Module = @field(tab_modules, field.name);
                if (!@hasDecl(Module.tab, hook_name)) return false;
                return @field(Module.tab, hook_name)(self);
            }
        }
        return false;
    }

    /// Tab-local keybind dispatch. Walks the active tab's
    /// keybindings looking for a key match. If the user's keys.srf
    /// has a `scope::<tab>` override for the active tab, that
    /// list is consulted (action names resolved against the tab's
    /// local `Action` enum). Otherwise the tab module's
    /// `default_bindings` is used. On match, invokes
    /// `tab.handleAction(state, app, action)` and returns `true`.
    /// Returns `false` if no binding matched.
    ///
    /// Called as a fallback AFTER the global keymap; under the
    /// "globals always win" rule, tabs are forbidden (by validator
    /// at comptime, by user-config check at runtime) from binding
    /// keys that are already global, so a key reaching here is by
    /// definition not a global keybind.
    ///
    /// Adding a tab-local action: declare it in the tab's `Action`
    /// enum, bind it in `default_bindings`, and `handleAction` runs
    /// it. No edit to `tui.zig` required.
    fn dispatchTabLocalKey(self: *App, key: vaxis.Key) bool {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                const state_ptr = &@field(self.states, field.name);

                // Prefer user overrides if present for this tab.
                if (self.keymap.tabOverridesFor(field.name)) |overrides| {
                    for (overrides) |ovr| {
                        if (key.matches(ovr.key.codepoint, ovr.key.mods)) {
                            // Resolve action_name against the tab's Action enum.
                            const ActionT = Module.Action;
                            inline for (std.meta.fields(ActionT)) |af| {
                                if (std.mem.eql(u8, af.name, ovr.action_name)) {
                                    const action: ActionT = @enumFromInt(af.value);
                                    Module.tab.handleAction(state_ptr, self, action);
                                    return true;
                                }
                            }
                            // Action name didn't resolve; treat as
                            // unbound (silent — keys.srf parsing
                            // already validated the action exists,
                            // future work).
                            return false;
                        }
                    }
                    return false;
                }

                // No user overrides — use the tab's default_bindings.
                for (Module.meta.default_bindings) |binding| {
                    if (key.matches(binding.key.codepoint, binding.key.mods)) {
                        Module.tab.handleAction(state_ptr, self, binding.action);
                        return true;
                    }
                }
                return false;
            }
        }
        return false;
    }

    /// Return all formatted key strings bound to the given global
    /// `action`. Allocated in `arena`. Empty slice if no binding
    /// exists. Order matches the keymap's binding order.
    ///
    /// Used by the help overlay and dynamic status hints to render
    /// "actual current key" rather than hardcoded literals — so that
    /// rebinding a key in `keys.srf` updates the displayed name.
    pub fn keysForGlobal(self: *const App, arena: std.mem.Allocator, action: keybinds.Action) ![][]const u8 {
        var out: std.ArrayList([]const u8) = .empty;
        for (self.keymap.bindings) |b| {
            if (b.action != action) continue;
            var key_buf: [32]u8 = undefined;
            const s = keybinds.formatKeyCombo(b.key, &key_buf) orelse continue;
            try out.append(arena, try arena.dupe(u8, s));
        }
        return out.toOwnedSlice(arena);
    }

    /// Return all formatted key strings bound to the named tab-local
    /// action in the given tab's keymap. Looks up user overrides
    /// first (`tabOverridesFor(scope)`), falling back to the tab
    /// module's `default_bindings`. The action name is matched against
    /// variant names of the tab's `Action` enum.
    ///
    /// `scope` is the tab tag name (e.g. `"options"`, `"history"`)
    /// matching the `tab_modules` registry. Comptime so we can resolve
    /// the tab's Action enum type.
    pub fn keysForTabAction(
        self: *const App,
        arena: std.mem.Allocator,
        comptime scope: []const u8,
        action_tag_name: []const u8,
    ) ![][]const u8 {
        const Module = @field(tab_modules, scope);
        var out: std.ArrayList([]const u8) = .empty;

        // Prefer user overrides for this scope when present.
        if (self.keymap.tabOverridesFor(scope)) |overrides| {
            for (overrides) |ovr| {
                if (!std.mem.eql(u8, ovr.action_name, action_tag_name)) continue;
                var key_buf: [32]u8 = undefined;
                const s = keybinds.formatKeyCombo(ovr.key, &key_buf) orelse continue;
                try out.append(arena, try arena.dupe(u8, s));
            }
            return out.toOwnedSlice(arena);
        }

        // No overrides — read from the tab's default_bindings.
        for (Module.meta.default_bindings) |binding| {
            if (!std.mem.eql(u8, @tagName(binding.action), action_tag_name)) continue;
            var key_buf: [32]u8 = undefined;
            const s = keybinds.formatKeyCombo(binding.key, &key_buf) orelse continue;
            try out.append(arena, try arena.dupe(u8, s));
        }
        return out.toOwnedSlice(arena);
    }

    /// Convenience: like `keysForTabAction` but resolves to whichever
    /// tab is currently active. Comptime-walks `tab_modules` to find
    /// the matching scope.
    fn keysForActiveTabAction(
        self: *const App,
        arena: std.mem.Allocator,
        action_tag_name: []const u8,
    ) ![][]const u8 {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                return self.keysForTabAction(arena, field.name, action_tag_name);
            }
        }
        return &.{};
    }

    /// Handles keypresses in symbol_input mode (activated by `/`).
    /// Mini text input for typing a ticker symbol (e.g. AAPL, BRK.B, ^GSPC).
    fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
        switch (input_buffer.handleKey(&self.input_buf, &self.input_len, key)) {
            .cancelled => {
                self.mode = .normal;
                self.setStatus("Cancelled");
                return ctx.consumeAndRedraw();
            },
            .edited => return ctx.consumeAndRedraw(),
            .ignored => {},
            .committed => {
                // Commit: uppercase the input, set as active symbol, switch to quote tab
                if (self.input_len > 0) {
                    for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*);
                    @memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]);
                    self.symbol = self.symbol_buf[0..self.input_len];
                    self.symbol_owned = true;
                    self.has_explicit_symbol = true;
                    self.resetSymbolData();
                    self.active_tab = .quote;
                    self.loadTabData();
                    ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
                }
                self.mode = .normal;
                self.input_len = 0;
                return ctx.consumeAndRedraw();
            },
        }
    }

    fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
        // Ctrl+L: full screen redraw (standard TUI convention, not configurable)
        if (key.codepoint == 'l' and key.mods.ctrl) {
            ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
            return ctx.consumeAndRedraw();
        }

        const action = self.keymap.matchAction(key) orelse {
            // No global binding matched. Fall back to tab-local
            // dispatch — the active tab may bind this key in its
            // `default_bindings`. Globals win (no overlap allowed,
            // enforced by validator + user-config check), so
            // reaching here means the key is purely tab-local.
            if (self.dispatchTabLocalKey(key)) return ctx.consumeAndRedraw();
            return;
        };
        switch (action) {
            .quit => {
                ctx.quit = true;
            },
            .symbol_input => {
                self.mode = .symbol_input;
                self.input_len = 0;
                return ctx.consumeAndRedraw();
            },
            .refresh => {
                // Two-phase so the "Refreshing..." indicator paints
                // before the (blocking) refresh runs. Flag it and
                // redraw now; the armed poll tick runs the actual
                // refresh one frame later (see the `.tick` branch).
                self.refresh_pending = true;
                return ctx.consumeAndRedraw();
            },
            .prev_tab => {
                self.prevTab();
                self.scroll_offset = 0;
                self.loadTabData();
                ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
                return ctx.consumeAndRedraw();
            },
            .next_tab => {
                self.nextTab();
                self.scroll_offset = 0;
                self.loadTabData();
                ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
                return ctx.consumeAndRedraw();
            },
            .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8, .tab_9 => {
                const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
                if (idx < tabs.len) {
                    const target = tabs[idx];
                    if (self.isDisabled(target)) return;
                    self.active_tab = target;
                    self.scroll_offset = 0;
                    self.loadTabData();
                    ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
                    return ctx.consumeAndRedraw();
                }
            },
            .select_next => {
                self.moveBy(1);
                return ctx.consumeAndRedraw();
            },
            .select_prev => {
                self.moveBy(-1);
                return ctx.consumeAndRedraw();
            },
            .scroll_down => {
                const half = @max(1, self.visible_height / 2);
                self.scroll_offset += half;
                return ctx.consumeAndRedraw();
            },
            .scroll_up => {
                const half = @max(1, self.visible_height / 2);
                if (self.scroll_offset > half) self.scroll_offset -= half else self.scroll_offset = 0;
                return ctx.consumeAndRedraw();
            },
            .page_down => {
                self.scroll_offset += self.visible_height;
                return ctx.consumeAndRedraw();
            },
            .page_up => {
                if (self.scroll_offset > self.visible_height)
                    self.scroll_offset -= self.visible_height
                else
                    self.scroll_offset = 0;
                return ctx.consumeAndRedraw();
            },
            .scroll_top => {
                self.scroll_offset = 0;
                self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.top});
                return ctx.consumeAndRedraw();
            },
            .scroll_bottom => {
                self.scroll_offset = std.math.maxInt(usize) / 2; // clamped during draw...divide by 2 to avoid overflow if arithmetic is done
                self.dispatchVoid("onScroll", .{tab_framework.ScrollEdge.bottom});
                return ctx.consumeAndRedraw();
            },
            .help => {
                self.mode = .help;
                self.scroll_offset = 0;
                return ctx.consumeAndRedraw();
            },
            .reload_portfolio => {
                tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
                return ctx.consumeAndRedraw();
            },
        }
    }

    /// Returns true if this wheel event should be suppressed (too
    /// close in time to the last one).
    ///
    /// Terminals batch ~3-5 wheel events per physical detent.
    /// Whether you want to absorb them depends on the semantics
    /// of what wheel-up/down does in the current view:
    ///
    /// - **Cursor-move semantics** ("one detent = one row"):
    ///   call this and bail on `true`. This is what cursor-bearing
    ///   tabs use via `moveBy` → `onCursorMove`, and what the
    ///   account-picker modal uses. Without debounce, one detent
    ///   jumps 5 rows.
    ///
    /// - **Viewport-scroll semantics** ("one detent = N rows of
    ///   scroll"): do NOT debounce. Burst delivery at, say, 3 rows
    ///   per event × 5 events = 15 rows feels like fast scroll
    ///   rather than a glitch, and matches what users expect from
    ///   non-cursor views (quote chart, perf table, etc).
    ///
    /// The state lives on App because terminals don't distinguish
    /// "wheel events on the picker" from "wheel events on the
    /// portfolio rows" — there's one ratcheting clock for the
    /// whole app, even when the active surface changes between
    /// events.
    pub fn shouldDebounceWheel(self: *App) bool {
        // wall-clock required: input-event debounce needs the actual
        // monotonic moment this wheel event arrived, not a frame-captured
        // approximation. `.awake` (monotonic) resists system clock jumps.
        const now: i128 = @intCast(std.Io.Timestamp.now(self.io, .awake).nanoseconds);
        if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return true;
        self.last_wheel_ns = now;
        return false;
    }

    /// Keyboard cursor movement (`j`/`k`/arrows). Positive = down,
    /// negative = up, magnitude = 1 per keypress. For tabs with a
    /// row cursor, moves the cursor; for tabs without one (or with
    /// empty rows), adjusts scroll_offset.
    ///
    /// No debounce — keyboard input isn't bursty the way wheel
    /// events are.
    fn moveBy(self: *App, n: isize) void {
        if (self.activeTabHas("onCursorMove")) {
            if (self.dispatchBool("onCursorMove", .{n})) return;
            // Hook declined (empty rows) — fall through to scroll.
        }
        self.scrollViewportBy(n);
    }

    /// Mouse-wheel-driven movement. Positive = down, negative = up,
    /// magnitude = lines per detent (typically 3).
    ///
    /// Dispatch order:
    ///   1. `onWheelMove` if declared — tab decides what wheel means
    ///      (review tab uses this to ALWAYS scroll viewport rather
    ///      than mix with cursor movement).
    ///   2. `onCursorMove` — legacy "wheel moves cursor" behavior
    ///      preserved for tabs that don't declare `onWheelMove`
    ///      (portfolio, options, history at time of writing).
    ///   3. Viewport scroll — fallback when the active tab has no
    ///      cursor or the cursor hook declines (empty rows).
    ///
    /// Debounce applies — terminals typically batch 3-5 wheel events
    /// per physical detent and we don't want to act on each one.
    fn wheelBy(self: *App, n: isize) void {
        if (self.activeTabHas("onWheelMove")) {
            if (self.shouldDebounceWheel()) return;
            if (self.dispatchBool("onWheelMove", .{n})) return;
            // Tab handled wheel as viewport scroll (returned false);
            // fall through.
            self.scrollViewportBy(n);
            return;
        }
        if (self.activeTabHas("onCursorMove")) {
            if (self.shouldDebounceWheel()) return;
            if (self.dispatchBool("onCursorMove", .{n})) return;
        }
        self.scrollViewportBy(n);
    }

    /// Adjust the App-level scroll_offset by `n` (signed, positive =
    /// down, negative = up). Clamps at 0; no upper bound (the draw
    /// path handles overflow visually). Shared by `moveBy` and
    /// `wheelBy` as their fallback.
    fn scrollViewportBy(self: *App, n: isize) void {
        if (n > 0) {
            self.scroll_offset += @intCast(n);
        } else {
            const abs: usize = @intCast(-n);
            if (self.scroll_offset > abs) self.scroll_offset -= abs else self.scroll_offset = 0;
        }
    }

    pub fn setActiveSymbol(self: *App, sym: []const u8) void {
        const len = @min(sym.len, self.symbol_buf.len);
        @memcpy(self.symbol_buf[0..len], sym[0..len]);
        for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
        self.symbol = self.symbol_buf[0..len];
        self.symbol_owned = true;
        self.has_explicit_symbol = true;
        self.resetSymbolData();
    }

    /// Open / close / re-target the symbol-info overlay. Behavior:
    ///   - overlay closed                         → open with `sym`.
    ///   - overlay open, same `sym` as cursor     → close.
    ///   - overlay open, different `sym`          → re-target to `sym`.
    ///
    /// `sym` is empty (no cursor on a symbol-bearing row) → close
    /// the overlay if open, no-op if closed.
    pub fn toggleOverlay(self: *App, sym: []const u8) void {
        if (sym.len == 0) {
            self.overlay_symbol = "";
            return;
        }
        if (self.overlay_symbol.len > 0 and std.ascii.eqlIgnoreCase(self.overlay_symbol, sym)) {
            self.overlay_symbol = "";
            return;
        }
        const len = @min(sym.len, self.overlay_symbol_buf.len);
        @memcpy(self.overlay_symbol_buf[0..len], sym[0..len]);
        for (self.overlay_symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
        self.overlay_symbol = self.overlay_symbol_buf[0..len];
    }

    fn resetSymbolData(self: *App) void {
        // Tab-private symbol-bound state is dropped via each tab's
        // onSymbolChange hook (where defined). Distinct from
        // `tab.deinit` (App teardown) and `tab.reload` (drops AND
        // re-fetches) — these hooks just drop the cache; the next
        // `activate` will re-fetch lazily.
        self.broadcast("onSymbolChange", .{});

        // App-level shared per-symbol cache.
        self.symbol_data.clear(self.allocator);
        self.scroll_offset = 0;
    }

    fn refreshCurrentTab(self: *App) void {
        // Each tab's `reload` hook owns its full per-tab refresh
        // sequence: invalidate the svc cache, drop in-memory
        // state, and re-fetch via `loadData` (or whatever the
        // tab's loader is named). The framework dispatcher routes
        // to the active tab's reload; tabs that share data
        // (quote/performance) delegate via their reload bodies.
        //
        // Reload is contractually self-completing: when it
        // returns, the tab's state is fully refreshed. There's no
        // need for a follow-up `loadTabData` — `activate` would
        // see `state.loaded = true` and no-op.
        self.dispatchTry("reload", .{});

        // Live-quote re-fetch: the quote tab's freshness display
        // ("refreshed Xs ago") is driven by `states.quote.timestamp`,
        // which is independent of the candles cache. The user's
        // mental model for `r` includes "and the price ticker is
        // current as of NOW," so we hit the live-quote endpoint
        // here. Only on tabs that show a live quote.
        //
        // This is the one place where `r` reaches past the cache;
        // tab-switches that activate quote/performance don't trigger
        // it (they should keep using whatever cached price the user
        // last fetched). When live-streaming quotes ship someday,
        // this block goes away.
        switch (self.active_tab) {
            .quote, .performance => {
                if (self.symbol.len > 0) {
                    if (self.svc.getQuote(self.symbol, .{})) |q| {
                        self.states.quote.live = q;
                        // wall-clock required: records the exact moment
                        // this quote was served so the "refreshed Xs ago"
                        // display is honest about freshness.
                        self.states.quote.timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds();
                    } else |_| {}
                }
            },
            else => {},
        }
    }

    /// Activate the current tab — load any data it needs, set
    /// any per-tab UI state. Each tab's `activate` is responsible
    /// for self-gating (e.g. skipping when `app.symbol.len == 0`
    /// or when its data is already cached).
    pub fn loadTabData(self: *App) void {
        self.dispatchTry("activate", .{});
    }

    pub fn setStatus(self: *App, msg: []const u8) void {
        const len = @min(msg.len, self.status_msg.len);
        @memcpy(self.status_msg[0..len], msg[0..len]);
        self.status_len = len;
    }

    /// Cell pixel size for the active terminal, used by tabs that
    /// render bitmap charts via the Kitty graphics protocol. Falls
    /// back to (8, 16) when vaxis hasn't reported pixel dimensions
    /// yet (terminal didn't answer the size query, or we're early
    /// in startup before the first frame).
    ///
    /// Returns the dimensions vaxis itself would put in
    /// `DrawContext.cell_size`, so tabs don't have to thread `ctx`
    /// through their `drawContent` hook just to size an image.
    pub fn cellPixelSize(self: *const App) struct { width: u32, height: u32 } {
        const va = self.vx_app orelse return .{ .width = 8, .height = 16 };
        const screen = &va.vx.screen;
        if (screen.width == 0 or screen.height == 0) return .{ .width = 8, .height = 16 };
        const w = screen.width_pix / screen.width;
        const h = screen.height_pix / screen.height;
        return .{
            .width = if (w > 0) @as(u32, w) else 8,
            .height = if (h > 0) @as(u32, h) else 16,
        };
    }

    /// Returns the current status message. While a refresh is in
    /// flight, shows the in-progress indicator. Otherwise: a
    /// user-set message if present, else a dynamic default hint
    /// (global keys + the active tab's `status_hints`), prefixed
    /// with a "refreshed Xs ago" readout once a refresh has run.
    /// Allocated in `arena` for the dynamic forms; the user-set
    /// buffer is returned by reference.
    fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 {
        if (self.refresh_pending) return "Refreshing...";
        if (self.status_len > 0) return self.status_msg[0..self.status_len];
        const hint = self.buildDefaultStatusHint(arena) catch
            "h/l tabs | j/k select | / symbol | ? help";
        if (self.last_refresh_s > 0) {
            // wall-clock required: per-frame "now" for the relative
            // "refreshed Xs ago" readout.
            const now_s = std.Io.Timestamp.now(self.io, .real).toSeconds();
            var ago_buf: [24]u8 = undefined;
            const ago = fmt.fmtTimeAgo(&ago_buf, self.last_refresh_s, now_s);
            if (ago.len > 0) {
                return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ ago, hint }) catch hint;
            }
        }
        return hint;
    }

    /// Build the dynamic default status hint: a small set of always-
    /// shown global keys (tab nav, cursor, symbol input, help) plus
    /// the active tab's `status_hints` actions resolved against its
    /// current bindings. Each fragment is `key label`.
    fn buildDefaultStatusHint(self: *App, arena: std.mem.Allocator) ![]const u8 {
        var fragments: std.ArrayListUnmanaged(StatusHintFragment) = .empty;

        // Always-shown globals. Each fragment uses the FIRST bound
        // key for the action; full lists go to the help overlay.
        const globals = [_]struct {
            action: keybinds.Action,
            label: []const u8,
        }{
            .{ .action = .prev_tab, .label = "tabs" },
            .{ .action = .select_next, .label = "select" },
            .{ .action = .symbol_input, .label = "symbol" },
            .{ .action = .help, .label = "help" },
        };
        for (globals) |g| {
            const keys = try self.keysForGlobal(arena, g.action);
            if (keys.len == 0) continue;
            try fragments.append(arena, .{ .key = keys[0], .label = g.label });
        }

        // Active tab's status_hints — comptime walk to get the right
        // Action enum + label table.
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                for (Module.meta.status_hints) |hint_action| {
                    const action_name = @tagName(hint_action);
                    const keys = try self.keysForTabAction(arena, field.name, action_name);
                    if (keys.len == 0) continue;
                    const label = Module.meta.action_labels.get(hint_action);
                    if (label.len == 0) continue;
                    try fragments.append(arena, .{ .key = keys[0], .label = label });
                }
            }
        }

        return formatStatusHint(arena, fragments.items);
    }

    fn deinitData(self: *App) void {
        // Cancel the in-flight background workers before tearing
        // anything down. Workers borrow `summary.allocations`
        // symbol pointers, which `self.portfolio.deinit` below
        // frees — canceling first guarantees no worker can still
        // be reading symbol strings during teardown.
        self.portfolio.cancelLoad();
        self.symbol_data.deinit(self.allocator);
        // Comptime walk every tab in the registry. Hand-enumerated
        // lists drift — review_tab and performance_tab were silently
        // missed in earlier hand-edits, leaking their allocator-backed
        // state on quit. The framework's tab_modules registry is the
        // single source of truth; iterating it here makes "add a new
        // tab" a one-edit change in tui.zig.
        //
        // Order doesn't matter: each tab's deinit only touches its
        // own State plus app-shared fields it explicitly owns. The
        // cross-tab `app.portfolio.deinit` runs after, in case a tab
        // wants to read shared portfolio data during its own deinit
        // (currently none do, but the ordering is the safest default).
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            const Module = @field(tab_modules, field.name);
            const state_ptr = &@field(self.states, field.name);
            Module.tab.deinit(state_ptr, self);
        }
        self.portfolio.deinit();
    }

    fn reloadPortfolioFile(self: *App) void {
        tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
    }

    /// First (anchor) portfolio path, used for sibling-file
    /// derivation (`accounts.srf`, `metadata.srf`,
    /// `transaction_log.srf`, history dir). Returns null when
    /// no portfolio is loaded. Delegates to
    /// `PortfolioData.anchorPath`; mirrors `LoadedPortfolio.anchor`
    /// on the CLI side.
    pub fn anchorPath(self: *const App) ?[]const u8 {
        return self.portfolio.anchorPath();
    }

    // ── Drawing ──────────────────────────────────────────────────

    fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface {
        const self: *App = @ptrCast(@alignCast(ptr));
        const max_size = ctx.max.size();

        // Per-frame tick dispatch to the active tab. Tabs that
        // don't declare a `tick` hook fall through to the
        // framework's `noopTick`. Used for polling-based async
        // work (future observation engine async dispatch, etc.).
        self.frame +%= 1;
        self.dispatchVoid("tick", .{self.frame});

        if (max_size.height < 3) {
            return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} };
        }

        self.visible_height = max_size.height -| 2;

        var children: std.ArrayList(vaxis.vxfw.SubSurface) = .empty;

        const tab_surface = try self.drawTabBar(ctx, max_size.width);
        try children.append(ctx.arena, .{ .origin = .{ .row = 0, .col = 0 }, .surface = tab_surface });

        const content_height = max_size.height - 2;
        const content_surface = try self.drawContent(ctx, max_size.width, content_height);
        try children.append(ctx.arena, .{ .origin = .{ .row = 1, .col = 0 }, .surface = content_surface });

        const status_surface = try self.drawStatusBar(ctx, max_size.width);
        try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface });

        // Symbol overlay (`K` key). Anchored to the bottom-right
        // of the content area with z_index=1 so the vaxis
        // compositor renders it on top of the tab content. Width
        // and height auto-fit; we clamp the origin so it always
        // stays inside the viewport even on narrow terminals.
        if (self.overlay_symbol.len > 0) {
            if (try self.drawSymbolOverlay(ctx, max_size.width, content_height)) |ov| {
                const row: u16 = if (ov.size.height + 1 < max_size.height - 1)
                    @intCast(max_size.height - 1 - ov.size.height)
                else
                    1;
                const col: u16 = if (ov.size.width < max_size.width)
                    @intCast(max_size.width - ov.size.width)
                else
                    0;
                try children.append(ctx.arena, .{
                    .origin = .{ .row = row, .col = col },
                    .surface = ov,
                    .z_index = 1,
                });
            }
        }

        return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) };
    }

    fn drawTabBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
        const th = self.theme;
        const buf = try ctx.arena.alloc(vaxis.Cell, width);
        const inactive_style = th.tabStyle();
        @memset(buf, .{ .char = .{ .grapheme = " " }, .style = inactive_style });

        var col: usize = 0;
        for (tabs) |t| {
            const lbl = tabLabel(t);
            const is_active = t == self.active_tab;
            const is_disabled = self.isDisabled(t);
            const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;

            for (lbl) |ch| {
                if (col >= width) break;
                buf[col] = .{ .char = .{ .grapheme = glyph(ch) }, .style = tab_style };
                col += 1;
            }
        }

        // Right-align the active symbol if set
        if (self.symbol.len > 0) {
            const is_selected = self.isSymbolSelected();
            const prefix: []const u8 = if (is_selected) " * " else " ";
            const sym_label = try std.fmt.allocPrint(ctx.arena, "{s}{s} ", .{ prefix, self.symbol });
            if (width > sym_label.len + col) {
                const sym_start = width - sym_label.len;
                const sym_style: vaxis.Style = .{
                    .fg = theme.Theme.vcolor(if (is_selected) th.warning else th.info),
                    .bg = theme.Theme.vcolor(th.tab_bg),
                    .bold = is_selected,
                };
                for (0..sym_label.len) |i| {
                    buf[sym_start + i] = .{ .char = .{ .grapheme = glyph(sym_label[i]) }, .style = sym_style };
                }
            }
        }

        return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
    }

    /// Whether the given tab should be treated as disabled in
    /// the current App context. All migrated tabs are consulted
    /// via their framework-contract `isDisabled` hook. (Portfolio
    /// is the only remaining unmigrated tab; it has no disabled
    /// predicate today.)
    fn isDisabled(self: *App, t: Tab) bool {
        return self.appPredicate(t, "isDisabled");
    }

    fn isSymbolSelected(self: *App) bool {
        // Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
        if (self.active_tab != .portfolio) return false;
        if (self.states.portfolio.rows.items.len == 0) return false;
        if (self.states.portfolio.cursor >= self.states.portfolio.rows.items.len) return false;
        return std.mem.eql(u8, self.states.portfolio.rows.items[self.states.portfolio.cursor].symbol, self.symbol);
    }

    fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface {
        const th = self.theme;
        const content_style = th.contentStyle();
        const buf_size: usize = @as(usize, width) * height;
        const buf = try ctx.arena.alloc(vaxis.Cell, buf_size);
        @memset(buf, .{ .char = .{ .grapheme = " " }, .style = content_style });

        if (self.mode == .help) {
            try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
        } else {
            try self.dispatchDraw(ctx.arena, buf, width, height);
        }

        return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} };
    }

    /// Dispatch the active tab's draw hook. Each tab declares
    /// EXACTLY ONE of `buildStyledLines` (line-list rendering;
    /// App handles scroll clamping + cell rendering) or
    /// `drawContent` (direct buffer; for layouts that don't fit
    /// the line-list shape, e.g. Kitty-graphics chart frames).
    /// The framework validator (in `tab_framework.zig`) enforces
    /// the exactly-one rule at compile time, so the
    /// `@hasDecl` branches below are total.
    fn dispatchDraw(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                const state_ptr = &@field(self.states, field.name);

                if (@hasDecl(Module, "drawContent")) {
                    return Module.drawContent(state_ptr, self, arena, buf, width, height);
                }
                // buildStyledLines — by the validator's exactly-one
                // rule, this branch must be reached when drawContent
                // isn't declared.
                const lines = try Module.buildStyledLines(state_ptr, self, arena);
                const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
                return self.drawStyledContent(arena, buf, width, height, lines[start..]);
            }
        }
    }

    pub fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
        for (lines, 0..) |line, row| {
            if (row >= height) break;
            // Fill row with style bg
            for (0..width) |ci| {
                buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style };
            }
            // Grapheme-based rendering (for braille / multi-byte Unicode lines)
            if (line.graphemes) |graphemes| {
                const cell_styles = line.cell_styles;
                for (0..@min(graphemes.len, width)) |ci| {
                    const s = if (cell_styles) |cs| cs[ci] else line.style;
                    buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
                }
            } else {
                // UTF-8 aware rendering: byte index and column index tracked separately
                var col: usize = 0;
                var bi: usize = 0;
                while (bi < line.text.len and col < width) {
                    var s = line.style;
                    // `spans` (if present) takes precedence over `alt_*`.
                    // Iterate forward; the LAST span that contains `col`
                    // wins. Spans are expected to be small (<= ~15 per
                    // line for the review tab's column count), so a
                    // linear scan per cell is fine.
                    //
                    // Span composition rule: spans describe a per-cell
                    // semantic intent (positive/negative/muted/etc.) —
                    // they own fg, bold, italic, underline. The row's
                    // base style owns bg (so a cursor-row's highlight
                    // bar reads as a continuous span across the whole
                    // row, not gappy where the colored cells land).
                    // `alt_*` (used by braille charts) keeps the
                    // legacy "replace fully" behavior since those
                    // overlays are decorative chart fills where the
                    // bg IS the signal.
                    if (line.spans) |spans| {
                        for (spans) |sp| {
                            if (col >= sp.start and col < sp.end) {
                                s = sp.style;
                                s.bg = line.style.bg;
                            }
                        }
                    } else if (line.alt_style) |alt| {
                        if (col >= line.alt_start and col < line.alt_end) s = alt;
                    }
                    const byte = line.text[bi];
                    if (byte < 0x80) {
                        // ASCII: single byte, single column
                        buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s };
                        bi += 1;
                    } else {
                        // Multi-byte UTF-8: determine sequence length
                        const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1;
                        const end = @min(bi + seq_len, line.text.len);
                        buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s };
                        bi = end;
                    }
                    col += 1;
                }
            }
        }
    }

    /// Render a prompt + live input buffer + blinking cursor + right-
    /// aligned hint into the status-bar cell buffer. Shared between
    /// `.symbol_input` and `.date_input` modes — only the prompt and
    /// hint text differ.
    fn renderInputPrompt(self: *App, buf: []vaxis.Cell, width: u16, prompt: []const u8, hint: []const u8) void {
        const t = self.theme;
        const prompt_style = t.inputStyle();
        @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });

        for (0..@min(prompt.len, width)) |i| {
            buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style };
        }
        const input = self.input_buf[0..self.input_len];
        for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| {
            buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style };
        }
        const cursor_pos = prompt.len + self.input_len;
        if (cursor_pos < width) {
            var cursor_style = prompt_style;
            cursor_style.blink = true;
            buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style };
        }
        if (width > hint.len + cursor_pos + 2) {
            const hint_start = width - hint.len;
            const hint_style = t.inputHintStyle();
            for (0..hint.len) |i| {
                buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style };
            }
        }
    }

    /// Build the symbol-info overlay surface. Returns null when
    /// nothing useful can be rendered (no portfolio loaded).
    /// Anchored bottom-right by the caller via SubSurface origin.
    ///
    /// Width is fixed at a comfortable column count (or whatever
    /// fits if the terminal is narrower); height auto-sizes to
    /// the line count. The compositor draws it on top of the
    /// content area via z_index=1.
    fn drawSymbolOverlay(
        self: *App,
        ctx: vaxis.vxfw.DrawContext,
        max_width: u16,
        max_height: u16,
    ) !?vaxis.vxfw.Surface {
        const lines = try self.buildOverlayLines(ctx.arena);
        if (lines.len == 0) return null;

        // Width: longest line + 4 for borders + a comfortable cap
        // so the box doesn't dominate wide terminals.
        const overlay_width_cap: usize = 60;
        var content_w: usize = 0;
        for (lines) |ln| {
            // Approximate display width by byte length. ASCII-only
            // for the v1 content; emoji/multibyte would need a
            // fancier measurement.
            if (ln.text.len > content_w) content_w = ln.text.len;
        }
        content_w = @min(content_w, overlay_width_cap);
        const total_w: u16 = @intCast(@min(content_w + 4, max_width));
        const total_h: u16 = @intCast(@min(lines.len + 2, max_height));
        if (total_w < 4 or total_h < 3) return null;

        const buf = try ctx.arena.alloc(vaxis.Cell, @as(usize, total_w) * @as(usize, total_h));
        const th = self.theme;
        const box_style = th.contentStyle();
        // Fill with spaces in box_style (overwrites whatever was
        // beneath; opaque overlay).
        for (buf) |*c| c.* = .{ .char = .{ .grapheme = " " }, .style = box_style };

        // Border. Top + bottom rows + first/last column.
        const tl = "┌";
        const tr = "┐";
        const bl = "└";
        const br = "┘";
        const h_border = "─";
        const v_border = "│";
        // Top
        buf[0] = .{ .char = .{ .grapheme = tl }, .style = box_style };
        buf[total_w - 1] = .{ .char = .{ .grapheme = tr }, .style = box_style };
        for (1..total_w - 1) |c| buf[c] = .{ .char = .{ .grapheme = h_border }, .style = box_style };
        // Bottom
        const last_row = (@as(usize, total_h) - 1) * @as(usize, total_w);
        buf[last_row] = .{ .char = .{ .grapheme = bl }, .style = box_style };
        buf[last_row + total_w - 1] = .{ .char = .{ .grapheme = br }, .style = box_style };
        for (1..total_w - 1) |c| buf[last_row + c] = .{ .char = .{ .grapheme = h_border }, .style = box_style };
        // Left + right verticals
        for (1..total_h - 1) |r| {
            buf[r * total_w] = .{ .char = .{ .grapheme = v_border }, .style = box_style };
            buf[r * total_w + total_w - 1] = .{ .char = .{ .grapheme = v_border }, .style = box_style };
        }

        // Content rows: render each StyledLine starting at col 2,
        // row 1+i, capped at content_w bytes.
        const inner_w: usize = @as(usize, total_w) - 4;
        for (lines, 0..) |line, i| {
            const row = 1 + i;
            if (row >= total_h - 1) break;
            const row_off = row * @as(usize, total_w) + 2;
            // Reset interior cells to the line's style.
            for (0..inner_w) |c| {
                buf[row_off + c] = .{ .char = .{ .grapheme = " " }, .style = line.style };
            }
            // Write text bytes (ASCII assumption; if a name has
            // multibyte characters we'd need fancier handling but
            // in practice security names are ASCII).
            const limit = @min(line.text.len, inner_w);
            for (0..limit) |c| {
                buf[row_off + c] = .{
                    .char = .{ .grapheme = glyph(line.text[c]) },
                    .style = line.style,
                };
            }
        }

        return .{
            .size = .{ .width = total_w, .height = total_h },
            .widget = self.widget(),
            .buffer = buf,
            .children = &.{},
        };
    }

    /// Build the overlay's content lines for the currently-active
    /// `overlay_symbol`. Pulls name + classification from
    /// `pd.classificationMap()`; pulls allocation data (shares,
    /// price, market value, weight) from `pd.summary`.
    fn buildOverlayLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
        var lines: std.ArrayList(StyledLine) = .empty;
        const th = self.theme;
        const sym = self.overlay_symbol;

        // Header: symbol (and name if available).
        const cm_opt = self.portfolio.classificationMap();
        const ce_opt: ?*const zfin.classification.ClassificationEntry = blk: {
            if (cm_opt) |cm| {
                for (cm.entries) |*e| {
                    if (std.mem.eql(u8, e.symbol, sym)) break :blk e;
                }
            }
            break :blk null;
        };

        // Name: same policy as the quote tab and CLI `quote` —
        // metadata.srf `name::` first, then the ETF profile's fund
        // name. The ETF fallback only applies when the overlay
        // targets the active symbol, since `symbol_data` holds the
        // profile for `self.symbol`, not necessarily `sym`.
        const etf_fallback: ?[]const u8 = if (std.mem.eql(u8, sym, self.symbol))
            (if (self.symbol_data.etf_profile) |p| p.name else null)
        else
            null;
        const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback);
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
            .style = th.headerStyle(),
        });
        if (resolved_name) |nm| {
            if (nm.len > 0 and !std.mem.eql(u8, nm, sym)) {
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " {s}", .{nm}),
                    .style = th.contentStyle(),
                });
            }
        }
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

        // Classification rows.
        if (ce_opt) |ce| {
            if (ce.asset_class) |ac| {
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Asset class: {s}", .{ac}),
                    .style = th.contentStyle(),
                });
            }
            if (ce.sector) |s| {
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Sector:      {s}", .{s}),
                    .style = th.contentStyle(),
                });
            }
            if (ce.geo) |g| {
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Geo:         {s}", .{g}),
                    .style = th.contentStyle(),
                });
            }
        } else {
            try lines.append(arena, .{
                .text = " (no metadata.srf entry)",
                .style = th.mutedStyle(),
            });
        }

        // Allocation row from pd.summary.
        if (self.portfolio.summary) |summary| {
            for (summary.allocations) |a| {
                if (!std.mem.eql(u8, a.symbol, sym)) continue;
                try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Shares:  {d:.4}", .{a.shares}),
                    .style = th.contentStyle(),
                });
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Price:   {f}", .{Money.from(a.current_price)}),
                    .style = th.contentStyle(),
                });
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Value:   {f}", .{Money.from(a.market_value)}),
                    .style = th.contentStyle(),
                });
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, " Weight:  {d:.2}%", .{a.weight * 100.0}),
                    .style = th.contentStyle(),
                });
                break;
            }
        }

        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        try lines.append(arena, .{
            .text = " K to close",
            .style = th.mutedStyle(),
        });

        return lines.toOwnedSlice(arena);
    }

    fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
        const t = self.theme;
        const buf = try ctx.arena.alloc(vaxis.Cell, width);

        // Truly global modes: symbol input is App-owned.
        if (self.mode == .symbol_input) {
            self.renderInputPrompt(buf, width, "Symbol: ", " Enter=confirm  Esc=cancel ");
            return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
        }

        // Tab-internal modal? Active tab declares `statusOverride`
        // and is in a modal sub-state.
        if (self.activeTabStatusOverride()) |override| {
            switch (override) {
                .hint => |text| {
                    const prompt_style = t.inputStyle();
                    @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
                    for (0..@min(text.len, width)) |i| {
                        buf[i] = .{ .char = .{ .grapheme = glyph(text[i]) }, .style = prompt_style };
                    }
                },
                .input_prompt => |ip| self.renderInputPrompt(buf, width, ip.prompt, ip.hint),
            }
            return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
        }

        // Default status bar: getStatus() + optional account-filter
        // suffix on the portfolio tab.
        const status_style = t.statusStyle();
        @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
        if (self.states.portfolio.account_filter != null and self.active_tab == .portfolio) {
            const af = self.states.portfolio.account_filter.?;
            const msg = self.getStatus(ctx.arena);
            const filter_text = std.fmt.allocPrint(ctx.arena, "{s}  [Account: {s}]", .{ msg, af }) catch msg;
            for (0..@min(filter_text.len, width)) |i| {
                buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
            }
        } else {
            const msg = self.getStatus(ctx.arena);
            for (0..@min(msg.len, width)) |i| {
                buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
            }
        }

        return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
    }

    /// Call the active tab's `statusOverride` hook (when declared)
    /// and return its result. Comptime-walks `tab_modules` to find
    /// the matching scope. Used by `drawStatusBar` to let tabs
    /// take over the status row during tab-internal modals.
    fn activeTabStatusOverride(self: *App) ?tab_framework.StatusOverride {
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                if (!@hasDecl(Module.tab, "statusOverride")) return null;
                const state_ptr = &@field(self.states, field.name);
                return Module.tab.statusOverride(state_ptr, self);
            }
        }
        return null;
    }

    // ── Help ─────────────────────────────────────────────────────

    fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
        // Resolve the help-overlay data upfront (key strings + labels)
        // so the renderer is a pure function over the pre-resolved data.
        // Active-tab section requires a comptime walk to dispatch on
        // `self.active_tab`'s local Action enum; the data we collect
        // is type-erased to `[]const HelpRow`.
        var globals: std.ArrayListUnmanaged(HelpRow) = .empty;
        const global_actions = comptime std.enums.values(keybinds.Action);
        for (global_actions) |action| {
            const keys = try self.keysForGlobal(arena, action);
            if (keys.len == 0) continue;
            try globals.append(arena, .{
                .keys = try std.mem.join(arena, ", ", keys),
                .label = keybinds.action_labels.get(action),
            });
        }

        var tab_rows: std.ArrayListUnmanaged(HelpRow) = .empty;
        inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
            if (std.mem.eql(u8, field.name, @tagName(self.active_tab))) {
                const Module = @field(tab_modules, field.name);
                const tab_actions = comptime std.enums.values(Module.Action);
                inline for (tab_actions) |action| {
                    const action_name = @tagName(action);
                    const label = Module.meta.action_labels.get(action);
                    if (label.len > 0) {
                        const keys = try self.keysForTabAction(arena, field.name, action_name);
                        const keys_str = if (keys.len == 0)
                            try arena.dupe(u8, "(unbound)")
                        else
                            try std.mem.join(arena, ", ", keys);
                        try tab_rows.append(arena, .{
                            .keys = keys_str,
                            .label = label,
                        });
                    }
                }
            }
        }

        // Active tab name (without the registry-position prefix) for
        // the section header.
        const active_tab_label = tabLabel(self.active_tab);
        const trimmed = std.mem.trim(u8, active_tab_label, " ");
        const colon_pos = std.mem.indexOfScalar(u8, trimmed, ':') orelse 0;
        const active_tab_name = if (colon_pos > 0) trimmed[colon_pos + 1 ..] else trimmed;

        return buildHelpLines(arena, self.theme, .{
            .globals = globals.items,
            .tab_rows = tab_rows.items,
            .active_tab_name = active_tab_name,
        });
    }

    // ── Tab navigation ───────────────────────────────────────────

    fn nextTab(self: *App) void {
        const idx = @intFromEnum(self.active_tab);
        var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
        // Skip disabled tabs (earnings for ETFs, analysis without portfolio)
        var tries: usize = 0;
        while (self.isDisabled(tabs[next_idx]) and tries < tabs.len) : (tries += 1)
            next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
        self.active_tab = tabs[next_idx];
    }

    fn prevTab(self: *App) void {
        const idx = @intFromEnum(self.active_tab);
        var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
        // Skip disabled tabs (earnings for ETFs, analysis without portfolio)
        var tries: usize = 0;
        while (self.isDisabled(tabs[prev_idx]) and tries < tabs.len) : (tries += 1)
            prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
        self.active_tab = tabs[prev_idx];
    }
};

// ── Utility functions ────────────────────────────────────────

pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme.Theme) !void {
    // Local shadows the `chart` module import; use a shorter name for
    // the local BrailleChart handle.
    var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
    // No deinit needed: arena handles cleanup

    // Cell budget per row: 2 leading spaces + n_cols chart cells +
    // 1 separator space + up to `money_label_max_bytes` for the
    // price label. Sizing this too small silently truncates labels
    // for portfolios over $1M; see `fmt.money_label_max_bytes`.
    const label_cells: usize = 1 + fmt.money_label_max_bytes;
    const row_cells: usize = 2 + br.n_cols + label_cells;

    const bg = th.bg;
    for (0..br.chart_height) |row| {
        const graphemes = try arena.alloc([]const u8, row_cells);
        const styles = try arena.alloc(vaxis.Style, row_cells);
        var gpos: usize = 0;

        // 2 leading spaces
        graphemes[gpos] = " ";
        styles[gpos] = .{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
        gpos += 1;
        graphemes[gpos] = " ";
        styles[gpos] = styles[0];
        gpos += 1;

        // Chart columns
        for (0..br.n_cols) |col| {
            const pattern = br.pattern(row, col);
            graphemes[gpos] = fmt.brailleGlyph(pattern);
            if (pattern != 0) {
                styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) };
            } else {
                styles[gpos] = .{ .fg = theme.Theme.vcolor(bg), .bg = theme.Theme.vcolor(bg) };
            }
            gpos += 1;
        }

        // Right-side price labels
        if (row == 0) {
            const lbl = try std.fmt.allocPrint(arena, " {s}", .{br.maxLabel()});
            for (lbl) |ch| {
                if (gpos < graphemes.len) {
                    graphemes[gpos] = glyph(ch);
                    styles[gpos] = .{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
                    gpos += 1;
                }
            }
        } else if (row == br.chart_height - 1) {
            const lbl = try std.fmt.allocPrint(arena, " {s}", .{br.minLabel()});
            for (lbl) |ch| {
                if (gpos < graphemes.len) {
                    graphemes[gpos] = glyph(ch);
                    styles[gpos] = .{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };
                    gpos += 1;
                }
            }
        }

        try lines.append(arena, .{
            .text = "",
            .style = .{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(bg) },
            .graphemes = graphemes[0..gpos],
            .cell_styles = styles[0..gpos],
        });
    }

    // Date axis below chart
    {
        var start_buf: [8]u8 = undefined;
        var end_buf: [8]u8 = undefined;
        const start_label = br.fmtAxisDate(br.start_date, &start_buf);
        const end_label = br.fmtAxisDate(br.end_date, &end_buf);
        const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) };

        const date_graphemes = try arena.alloc([]const u8, row_cells);
        const date_styles = try arena.alloc(vaxis.Style, row_cells);
        var dpos: usize = 0;

        // 2 leading spaces
        date_graphemes[dpos] = " ";
        date_styles[dpos] = muted_style;
        dpos += 1;
        date_graphemes[dpos] = " ";
        date_styles[dpos] = muted_style;
        dpos += 1;

        // Start date label
        for (start_label) |ch| {
            if (dpos < date_graphemes.len) {
                date_graphemes[dpos] = glyph(ch);
                date_styles[dpos] = muted_style;
                dpos += 1;
            }
        }

        // Gap between labels
        const total_width = br.n_cols;
        if (total_width > start_label.len + end_label.len) {
            const gap = total_width - start_label.len - end_label.len;
            for (0..gap) |_| {
                if (dpos < date_graphemes.len) {
                    date_graphemes[dpos] = " ";
                    date_styles[dpos] = muted_style;
                    dpos += 1;
                }
            }
        }

        // End date label
        for (end_label) |ch| {
            if (dpos < date_graphemes.len) {
                date_graphemes[dpos] = glyph(ch);
                date_styles[dpos] = muted_style;
                dpos += 1;
            }
        }

        try lines.append(arena, .{
            .text = "",
            .style = .{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(bg) },
            .graphemes = date_graphemes[0..dpos],
            .cell_styles = date_styles[0..dpos],
        });
    }
}

pub const loadWatchlist = cli.loadWatchlist;
pub const freeWatchlist = cli.freeWatchlist;

/// Write the full default keymap to `out` in keys.srf format,
/// covering both the global section and each tab's local bindings.
///
/// Output shape:
///
/// ```
/// #!srfv1
/// # ... preamble + format docs ...
///
/// # ── Global ──
/// action::quit,key::q
/// ...
///
/// # ── Tab: portfolio ──
/// scope::portfolio,action::toggle_account_picker,key::a
/// ...
/// ```
///
/// Per-tab sections are emitted in `tab_modules` declaration order
/// and only when the tab has at least one default binding (skipping
/// empty sections keeps the file compact).
///
/// Caller is responsible for flushing.
fn writeDefaultKeys(out: *std.Io.Writer) !void {
    try keybinds.printDefaultsHeader(out);

    try keybinds.printSectionHeader(out, "Global");
    try keybinds.printGlobalBindings(out);

    inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
        const Module = @field(tab_modules, field.name);
        if (Module.meta.default_bindings.len == 0) continue;

        const heading = "Tab: " ++ field.name;
        try keybinds.printSectionHeader(out, heading);

        for (Module.meta.default_bindings) |binding| {
            try keybinds.printScopedBinding(
                out,
                field.name,
                @tagName(binding.action),
                binding.key,
            );
        }
    }
}

/// CLI entry point for `--default-keys`. Writes the full default
/// keymap to stdout. See `writeDefaultKeys` for output format.
fn printDefaultKeys(io: std.Io) !void {
    var buf: [4096]u8 = undefined;
    var writer = std.Io.File.stdout().writer(io, &buf);
    const out = &writer.interface;
    try writeDefaultKeys(out);
    try out.flush();
}

/// Result of parsing the `interactive` command's local args
/// (everything after `interactive`). Produced by `parseArgs`,
/// consumed by `run`. The symbol is uppercased into an inline
/// buffer so the struct owns its storage and stays copy-safe (no
/// borrow into the caller's `args`).
const ParsedArgs = struct {
    /// Set when a print-and-exit flag (`--default-keys` /
    /// `--default-theme`) was seen. Recorded on the FIRST
    /// occurrence so `--default-keys --default-theme` keeps the
    /// historical first-token-wins behavior. `run` honors this
    /// only after the whole arg list has validated, so trailing
    /// bogus flags are still rejected (`--default-keys --nope`
    /// exits non-zero rather than silently printing).
    print_and_exit: ?PrintAndExit = null,
    /// Uppercased symbol storage; `symbol()` returns the live slice.
    symbol_buf: [32]u8 = @splat(0),
    symbol_len: usize = 0,
    has_explicit_symbol: bool = false,
    skip_watchlist: bool = false,
    chart_config: chart.ChartConfig = .{},

    const PrintAndExit = enum { keys, theme };

    /// The uppercased symbol, or "" when none was supplied.
    fn symbol(self: *const ParsedArgs) []const u8 {
        return self.symbol_buf[0..self.symbol_len];
    }
};

/// Parse the `interactive` command's local args. Validates the
/// ENTIRE list before returning: unknown flags, missing flag
/// values, and flag-shaped values all yield `error.InvalidArgs`
/// even when they trail a print-and-exit flag. This is the fix for
/// `interactive --default-keys --bogus-flag` silently succeeding:
/// the print-and-exit flags no longer short-circuit the loop, so
/// the bogus flag reaches the catch-all reject branch.
///
/// Error messages go to stderr (suppressed under test). Pure apart
/// from those best-effort writes, so it's unit-testable without a
/// terminal.
fn parseArgs(io: std.Io, args: []const []const u8) error{InvalidArgs}!ParsedArgs {
    var result: ParsedArgs = .{};
    var i: usize = 0;
    while (i < args.len) : (i += 1) {
        if (std.mem.eql(u8, args[i], "--default-keys")) {
            if (result.print_and_exit == null) result.print_and_exit = .keys;
        } else if (std.mem.eql(u8, args[i], "--default-theme")) {
            if (result.print_and_exit == null) result.print_and_exit = .theme;
        } else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
            // -s / --symbol require a non-flag value. Bare `-s` and
            // `-s --chart …` are both user errors — surface them
            // explicitly rather than silently dropping the flag.
            const flag = args[i];
            if (i + 1 >= args.len) {
                stderr.print(io, "Error: ");
                stderr.print(io, flag);
                stderr.print(io, " requires a symbol value\n");
                return error.InvalidArgs;
            }
            i += 1;
            const value = args[i];
            if (value.len > 0 and value[0] == '-') {
                stderr.print(io, "Error: ");
                stderr.print(io, flag);
                stderr.print(io, " requires a symbol value, got flag: ");
                stderr.print(io, value);
                stderr.print(io, "\n");
                return error.InvalidArgs;
            }
            const len = @min(value.len, result.symbol_buf.len);
            _ = std.ascii.upperString(result.symbol_buf[0..len], value[0..len]);
            result.symbol_len = len;
            result.has_explicit_symbol = true;
            result.skip_watchlist = true;
        } else if (std.mem.eql(u8, args[i], "--chart")) {
            // Same shape as -s / --symbol: require a value, reject
            // flag-shaped values.
            if (i + 1 >= args.len) {
                stderr.print(io, "Error: --chart requires a value (e.g. 80x24)\n");
                return error.InvalidArgs;
            }
            i += 1;
            const value = args[i];
            if (value.len > 0 and value[0] == '-') {
                stderr.print(io, "Error: --chart requires a value, got flag: ");
                stderr.print(io, value);
                stderr.print(io, "\n");
                return error.InvalidArgs;
            }
            if (chart.ChartConfig.parse(value)) |cc| {
                result.chart_config = cc;
            } else {
                stderr.print(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: ");
                stderr.print(io, value);
                stderr.print(io, "\n");
                return error.InvalidArgs;
            }
        } else if (args[i].len > 0 and args[i][0] == '-') {
            // Any flag we didn't recognize. Reject explicitly rather
            // than silently passing through to the positional-symbol
            // branch (which would then ignore it).
            stderr.print(io, "Error: unknown flag: ");
            stderr.print(io, args[i]);
            stderr.print(io, "\nRun 'zfin interactive --help' for usage.\n");
            return error.InvalidArgs;
        } else if (args[i].len > 0) {
            const len = @min(args[i].len, result.symbol_buf.len);
            _ = std.ascii.upperString(result.symbol_buf[0..len], args[i][0..len]);
            result.symbol_len = len;
            result.has_explicit_symbol = true;
        }
    }
    return result;
}

/// Entry point for the interactive TUI.
/// `args` contains only command-local tokens (everything after `interactive`).
pub fn run(
    io: std.Io,
    allocator: std.mem.Allocator,
    config: zfin.Config,
    portfolio_patterns: []const []const u8,
    global_watchlist_path: ?[]const u8,
    args: []const []const u8,
    today: zfin.Date,
) !void {
    const watchlist_path: ?[]const u8 = global_watchlist_path;

    // Parse + validate the full arg list first. Print-and-exit
    // flags (`--default-keys` / `--default-theme`) are honored only
    // after validation, so trailing bogus flags still exit non-zero.
    const parsed = try parseArgs(io, args);
    if (parsed.print_and_exit) |which| {
        switch (which) {
            .keys => try printDefaultKeys(io),
            .theme => try theme.printDefaults(io),
        }
        return;
    }
    const symbol = parsed.symbol();
    const has_explicit_symbol = parsed.has_explicit_symbol;
    const skip_watchlist = parsed.skip_watchlist;
    const chart_config = parsed.chart_config;

    var keymap = blk: {
        const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
        const home = home_opt orelse break :blk keybinds.defaults();
        const keys_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "keys.srf" }) catch
            break :blk keybinds.defaults();
        defer allocator.free(keys_path);
        switch (keybinds.loadFromFileChecked(io, allocator, keys_path)) {
            .keymap => |km| break :blk km,
            .fallback => break :blk keybinds.defaults(),
            .err => |e| switch (e) {
                error.TabBindingShadowsGlobal => {
                    // User keys.srf has a `scope::<tab>` record whose
                    // key is also bound globally. Globals always win,
                    // so the scoped binding would be dead. Refuse to
                    // start so the user knows their config is broken.
                    var stderr_buf: [4096]u8 = undefined;
                    var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
                    try stderr_writer.interface.print(
                        "zfin: keys.srf has a tab-scoped binding whose key is already " ++
                            "bound in the global keymap. Tab-local bindings cannot override " ++
                            "global keys.\n  Edit {s} to remove the conflict, or remove the " ++
                            "global binding.\n",
                        .{keys_path},
                    );
                    try stderr_writer.interface.flush();
                    return error.KeyBindingConflict;
                },
            },
        }
    };
    defer keymap.deinit();

    // Surface per-record parse warnings (unknown action, malformed
    // key, etc.) to stderr. Non-fatal — the keymap is otherwise
    // usable; user just sees that some lines didn't take effect.
    if (keymap.warnings.len > 0) {
        var stderr_buf: [4096]u8 = undefined;
        var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
        for (keymap.warnings) |w| {
            try stderr_writer.interface.print("zfin: {s}\n", .{w});
        }
        try stderr_writer.interface.flush();
    }

    const loaded_theme = blk: {
        const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
        const home = home_opt orelse break :blk theme.default_theme;
        const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch
            break :blk theme.default_theme;
        defer allocator.free(theme_path);
        break :blk theme.loadFromFile(io, allocator, theme_path) orelse theme.default_theme;
    };

    var svc = try allocator.create(zfin.DataService);
    defer allocator.destroy(svc);
    svc.* = zfin.DataService.init(io, allocator, config);
    defer svc.deinit();

    var app_inst = try allocator.create(App);
    defer allocator.destroy(app_inst);
    app_inst.* = .{
        .allocator = allocator,
        .io = io,
        .today = today,
        .config = config,
        .svc = svc,
        .keymap = keymap,
        .theme = loaded_theme,
        .symbol = symbol,
        .has_explicit_symbol = has_explicit_symbol,
        .chart_config = chart_config,
        .portfolio = PortfolioData.init(.{ .gpa = allocator, .io = io, .svc = svc }),
    };
    // History tab requires explicit init (allocator-backed hash map);
    // other tabs use field defaults. The corresponding deinit lives
    // in `App.deinitData`.
    try tab_modules.history.tab.init(&app_inst.states.history, app_inst);

    // Resolve portfolio paths (matching cwd or ZFIN_HOME). pd.load
    // re-parses the files internally so it captures the merged
    // Portfolio + symbols and runs the Tier 0 work + spawns the
    // background workers in one place.
    var resolved_pf_paths: ?framework.ResolvedPaths = null;
    defer if (resolved_pf_paths) |rp| rp.deinit();
    if (!has_explicit_symbol) {
        if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| {
            resolved_pf_paths = rp;
        } else |_| {}
    }

    var resolved_wl: ?zfin.Config.ResolvedPath = null;
    defer if (resolved_wl) |r| r.deinit(allocator);
    if (!skip_watchlist) {
        const wl_path = watchlist_path orelse blk: {
            if (config.resolveUserFile(io, allocator, "watchlist.srf")) |r| {
                resolved_wl = r;
                break :blk @as(?[]const u8, r.path);
            }
            break :blk null;
        };
        if (wl_path) |path| {
            app_inst.watchlist = loadWatchlist(io, allocator, path);
            app_inst.watchlist_path = path;
        }
    }

    if (has_explicit_symbol and symbol.len > 0) {
        app_inst.active_tab = .quote;
    }

    // Load the portfolio synchronously before vaxis takes over the
    // terminal. pd.load parses the portfolio file(s), unifies the
    // watchlist symbols (caller-supplied + portfolio's own .watch
    // lots), fetches all prices in one parallel pass via
    // svc.loadAllPrices, and spawns the background workers. The
    // progress callbacks render the same stderr UI the CLI uses;
    // they're visible because we're still in normal-mode terminal.
    if (resolved_pf_paths) |rp| {
        const grand_total_estimate: usize = blk: {
            // We don't know the symbol count without parsing.
            // svc.loadAllPrices doesn't need it for correctness;
            // LoadProgress only uses it for the per-symbol index
            // display ("[5/28] Loading AAPL..."). Pass 0 to mean
            // "estimate from progress events." (LoadProgress
            // tolerates 0 by ignoring the slot.)
            break :blk 0;
        };
        var symbol_progress = cli.LoadProgress{
            .io = io,
            .svc = svc,
            .color = true,
            .index_offset = 0,
            .grand_total = grand_total_estimate,
        };
        var aggregate_progress = cli.AggregateProgress{ .io = io, .color = true };

        var watch_syms: std.ArrayList([]const u8) = .empty;
        defer watch_syms.deinit(allocator);
        if (app_inst.watchlist) |wl| {
            for (wl) |sym| try watch_syms.append(allocator, sym);
        }

        const lr = app_inst.portfolio.load(rp.paths, today, .{
            .progress = symbol_progress.callback(),
            .aggregate_progress = aggregate_progress.callback(),
            .watchlist_syms = watch_syms.items,
        }) catch |err| blk: {
            std.log.scoped(.tui).warn("portfolio load failed: {t}", .{err});
            break :blk null;
        };
        if (lr) |result| {
            // Print summary line matching the CLI's UX.
            cli.printLoadSummary(io, true, .{
                .total = result.total(),
                .from_cache = result.cached_count,
                .from_server = result.server_synced_count,
                .from_provider = result.provider_fetched_count,
                .failed = result.failed_count,
                .stale = result.stale_count,
            });
        }
    }

    defer freeWatchlist(allocator, app_inst.watchlist);
    defer app_inst.deinitData();

    {
        // vaxis 0.16 requires a pre-allocated app buffer, an Io, and
        // an env map. The buffer must outlive vx_app.
        var vx_app_buf: [4096]u8 = undefined;
        const environ_map = config.environ_map orelse return error.MissingEnvironMap;
        var vx_app = try vaxis.vxfw.App.init(io, allocator, @constCast(environ_map), &vx_app_buf);
        defer vx_app.deinit();
        app_inst.vx_app = &vx_app;
        defer app_inst.vx_app = null;
        defer {
            // Free any chart image before vaxis is torn down
            if (app_inst.states.quote.chart.image_id) |id| {
                vx_app.vx.freeImage(vx_app.tty.writer(), id);
                app_inst.states.quote.chart.image_id = null;
            }
            if (app_inst.states.projections.image_id) |id| {
                vx_app.vx.freeImage(vx_app.tty.writer(), id);
                app_inst.states.projections.image_id = null;
            }
        }
        try vx_app.run(app_inst.widget(), .{});
    }
}

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

const testing = std.testing;

test "colLabel plain left-aligned" {
    var buf: [32]u8 = undefined;
    const result = colLabel(&buf, "Name", 10, true, null);
    try testing.expectEqualStrings("Name      ", result);
    try testing.expectEqual(@as(usize, 10), result.len);
}

test "colLabel plain right-aligned" {
    var buf: [32]u8 = undefined;
    const result = colLabel(&buf, "Price", 10, false, null);
    try testing.expectEqualStrings("     Price", result);
}

test "colLabel with indicator left-aligned" {
    var buf: [64]u8 = undefined;
    const result = colLabel(&buf, "Name", 10, true, "\xe2\x96\xb2"); // ▲ = 3 bytes
    // Indicator + text + padding. Display width is 10, byte length is 10 - 1 + 3 = 12
    try testing.expectEqual(@as(usize, 12), result.len);
    try testing.expect(std.mem.startsWith(u8, result, "\xe2\x96\xb2")); // starts with ▲
    try testing.expect(std.mem.indexOf(u8, result, "Name") != null);
}

test "colLabel with indicator right-aligned" {
    var buf: [64]u8 = undefined;
    const result = colLabel(&buf, "Price", 10, false, "\xe2\x96\xbc"); // ▼
    try testing.expectEqual(@as(usize, 12), result.len);
    try testing.expect(std.mem.endsWith(u8, result, "Price"));
}

test "glyph ASCII returns single-char slice" {
    try testing.expectEqualStrings("A", glyph('A'));
    try testing.expectEqualStrings(" ", glyph(' '));
    try testing.expectEqualStrings("0", glyph('0'));
}

test "glyph non-ASCII returns space" {
    try testing.expectEqualStrings(" ", glyph(200));
}

test "Tab label" {
    try testing.expectEqualStrings(" 1:Portfolio ", tabLabel(.portfolio));
    try testing.expectEqualStrings(" 2:Analysis ", tabLabel(.analysis));
}

test "buildHelpLines: header, global section, active-tab section, footer" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const data: HelpData = .{
        .globals = &.{
            .{ .keys = "q, ctrl+c", .label = "Quit" },
            .{ .keys = "j", .label = "Select next" },
        },
        .tab_rows = &.{
            .{ .keys = "enter", .label = "Expand position" },
            .{ .keys = "s, space", .label = "Select symbol" },
        },
        .active_tab_name = "Portfolio",
    };

    const lines = try buildHelpLines(arena, theme.default_theme, data);

    var all: std.ArrayListUnmanaged(u8) = .empty;
    for (lines) |l| {
        try all.appendSlice(arena, l.text);
        try all.append(arena, '\n');
    }
    const text = all.items;

    // Title + section headers.
    try testing.expect(std.mem.indexOf(u8, text, "zfin TUI -- Keybindings") != null);
    try testing.expect(std.mem.indexOf(u8, text, "  Global") != null);
    try testing.expect(std.mem.indexOf(u8, text, "  Active Tab: Portfolio") != null);

    // Global rows.
    try testing.expect(std.mem.indexOf(u8, text, "q, ctrl+c") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Quit") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Select next") != null);

    // Tab rows.
    try testing.expect(std.mem.indexOf(u8, text, "Expand position") != null);
    try testing.expect(std.mem.indexOf(u8, text, "s, space") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Select symbol") != null);

    // Footer.
    try testing.expect(std.mem.indexOf(u8, text, "Mouse: click tabs") != null);
    try testing.expect(std.mem.indexOf(u8, text, "~/.config/zfin/keys.srf") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Press any key to close") != null);
}

test "buildHelpLines: empty tab_rows shows '(no tab-local actions)'" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const data: HelpData = .{
        .globals = &.{.{ .keys = "q", .label = "Quit" }},
        .tab_rows = &.{},
        .active_tab_name = "Earnings",
    };

    const lines = try buildHelpLines(arena, theme.default_theme, data);
    var all: std.ArrayListUnmanaged(u8) = .empty;
    for (lines) |l| {
        try all.appendSlice(arena, l.text);
        try all.append(arena, '\n');
    }
    const text = all.items;

    try testing.expect(std.mem.indexOf(u8, text, "Active Tab: Earnings") != null);
    try testing.expect(std.mem.indexOf(u8, text, "(no tab-local actions)") != null);
}

test "buildHelpLines: empty globals still renders title and section headers" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const data: HelpData = .{
        .globals = &.{},
        .tab_rows = &.{.{ .keys = "x", .label = "Do thing" }},
        .active_tab_name = "Quote",
    };

    const lines = try buildHelpLines(arena, theme.default_theme, data);
    var all: std.ArrayListUnmanaged(u8) = .empty;
    for (lines) |l| {
        try all.appendSlice(arena, l.text);
        try all.append(arena, '\n');
    }
    const text = all.items;

    try testing.expect(std.mem.indexOf(u8, text, "  Global") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Active Tab: Quote") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Do thing") != null);
}

test "formatStatusHint: joins fragments with ' | '" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const fragments = [_]StatusHintFragment{
        .{ .key = "h", .label = "tabs" },
        .{ .key = "j", .label = "select" },
        .{ .key = "/", .label = "symbol" },
        .{ .key = "?", .label = "help" },
    };
    const out = try formatStatusHint(arena, &fragments);
    try testing.expectEqualStrings("h tabs | j select | / symbol | ? help", out);
}

test "formatStatusHint: empty fragments returns empty string" {
    const out = try formatStatusHint(testing.allocator, &.{});
    try testing.expectEqualStrings("", out);
}

test "formatStatusHint: single fragment has no separator" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const fragments = [_]StatusHintFragment{
        .{ .key = "ctrl+s", .label = "save" },
    };
    const out = try formatStatusHint(arena, &fragments);
    try testing.expectEqualStrings("ctrl+s save", out);
}

// ── interactive parseArgs ─────────────────────────────────────

test "parseArgs: no args yields defaults" {
    const p = try parseArgs(std.testing.io, &.{});
    try testing.expect(p.print_and_exit == null);
    try testing.expectEqualStrings("", p.symbol());
    try testing.expect(!p.has_explicit_symbol);
    try testing.expect(!p.skip_watchlist);
    try testing.expectEqual(chart.ChartConfig{}, p.chart_config);
}

test "parseArgs: positional symbol is uppercased and marked explicit" {
    const p = try parseArgs(std.testing.io, &.{"vti"});
    try testing.expectEqualStrings("VTI", p.symbol());
    try testing.expect(p.has_explicit_symbol);
    // A bare positional does NOT skip the watchlist (only -s/--symbol does).
    try testing.expect(!p.skip_watchlist);
}

test "parseArgs: --symbol sets symbol and skips watchlist" {
    const p = try parseArgs(std.testing.io, &.{ "--symbol", "aapl" });
    try testing.expectEqualStrings("AAPL", p.symbol());
    try testing.expect(p.has_explicit_symbol);
    try testing.expect(p.skip_watchlist);
}

test "parseArgs: -s short form behaves like --symbol" {
    const p = try parseArgs(std.testing.io, &.{ "-s", "msft" });
    try testing.expectEqualStrings("MSFT", p.symbol());
    try testing.expect(p.skip_watchlist);
}

test "parseArgs: --default-keys requests the keys template" {
    const p = try parseArgs(std.testing.io, &.{"--default-keys"});
    try testing.expectEqual(ParsedArgs.PrintAndExit.keys, p.print_and_exit.?);
}

test "parseArgs: --default-theme requests the theme template" {
    const p = try parseArgs(std.testing.io, &.{"--default-theme"});
    try testing.expectEqual(ParsedArgs.PrintAndExit.theme, p.print_and_exit.?);
}

test "parseArgs: --default-keys with a trailing bogus flag is rejected" {
    // Regression: print-and-exit flags used to short-circuit before
    // the rest of the args were validated, so `--default-keys
    // --bogus-flag` silently printed and exited 0.
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{ "--default-keys", "--bogus-flag" }));
}

test "parseArgs: --default-theme with a trailing bogus flag is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{ "--default-theme", "--bogus-flag" }));
}

test "parseArgs: --default-keys with a valid trailing positional still requests keys (no error)" {
    // A trailing *positional* is harmless: print-and-exit takes
    // precedence and the positional is ignored. Only bogus flags error.
    const p = try parseArgs(std.testing.io, &.{ "--default-keys", "VTI" });
    try testing.expectEqual(ParsedArgs.PrintAndExit.keys, p.print_and_exit.?);
}

test "parseArgs: first print-and-exit flag wins" {
    const p = try parseArgs(std.testing.io, &.{ "--default-theme", "--default-keys" });
    try testing.expectEqual(ParsedArgs.PrintAndExit.theme, p.print_and_exit.?);
}

test "parseArgs: unknown flag is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{"--nope"}));
}

test "parseArgs: --symbol without a value is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{"--symbol"}));
}

test "parseArgs: --symbol followed by a flag is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{ "-s", "--chart" }));
}

test "parseArgs: --chart parses a valid WxH spec" {
    const p = try parseArgs(std.testing.io, &.{ "--chart", "800x600" });
    try testing.expectEqual(chart.ChartMode.kitty, p.chart_config.mode);
    try testing.expectEqual(@as(u32, 800), p.chart_config.max_width);
    try testing.expectEqual(@as(u32, 600), p.chart_config.max_height);
}

test "parseArgs: --chart without a value is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{"--chart"}));
}

test "parseArgs: --chart with an invalid spec is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{ "--chart", "nonsense" }));
}

test "parseArgs: --chart with a flag-shaped value is rejected" {
    try testing.expectError(error.InvalidArgs, parseArgs(std.testing.io, &.{ "--chart", "-x" }));
}

test "parseArgs: a symbol longer than the buffer is truncated, not overflowed" {
    const long = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // 36 chars > 32
    const p = try parseArgs(std.testing.io, &.{long});
    try testing.expectEqual(@as(usize, 32), p.symbol().len);
    try testing.expectEqualStrings(long[0..32], p.symbol());
}

test "writeDefaultKeys: includes preamble, global section, and per-tab sections" {
    var aw: std.Io.Writer.Allocating = .init(testing.allocator);
    defer aw.deinit();
    try writeDefaultKeys(&aw.writer);
    const out = aw.written();

    // Preamble.
    try testing.expect(std.mem.indexOf(u8, out, "#!srfv1") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Regenerate: zfin interactive --default-keys") != null);

    // Global section header + at least one global binding (un-scoped).
    try testing.expect(std.mem.indexOf(u8, out, "# ── Global ──") != null);
    try testing.expect(std.mem.indexOf(u8, out, "action::quit,key::q") != null);

    // At least one tab section appears, and bindings inside it carry
    // their `scope::<tab>,` prefix (the user-edit format).
    try testing.expect(std.mem.indexOf(u8, out, "# ── Tab: ") != null);
    try testing.expect(std.mem.indexOf(u8, out, "scope::") != null);
}

test "writeDefaultKeys: every registered tab with default_bindings has a section" {
    var aw: std.Io.Writer.Allocating = .init(testing.allocator);
    defer aw.deinit();
    try writeDefaultKeys(&aw.writer);
    const out = aw.written();

    inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
        const Module = @field(tab_modules, field.name);
        if (Module.meta.default_bindings.len == 0) continue;

        const heading = "# ── Tab: " ++ field.name ++ " ──";
        if (std.mem.indexOf(u8, out, heading) == null) {
            std.debug.print("missing tab section: {s}\n", .{heading});
            return error.MissingTabSection;
        }

        // And every binding for that tab must show up as a `scope::<tab>,action::<name>` line.
        inline for (Module.meta.default_bindings) |binding| {
            const needle = "scope::" ++ field.name ++ ",action::" ++ @tagName(binding.action);
            if (std.mem.indexOf(u8, out, needle) == null) {
                std.debug.print("missing binding line: {s}\n", .{needle});
                return error.MissingBindingLine;
            }
        }
    }
}

test "writeDefaultKeys: tab sections appear in tab_modules declaration order" {
    var aw: std.Io.Writer.Allocating = .init(testing.allocator);
    defer aw.deinit();
    try writeDefaultKeys(&aw.writer);
    const out = aw.written();

    var prev_pos: usize = 0;
    inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
        const Module = @field(tab_modules, field.name);
        if (Module.meta.default_bindings.len == 0) continue;

        const heading = "# ── Tab: " ++ field.name ++ " ──";
        const pos = std.mem.indexOf(u8, out, heading) orelse return error.MissingTabSection;
        try testing.expect(pos >= prev_pos);
        prev_pos = pos;
    }
}

test "renderBrailleToStyledLines: full price label renders for portfolios over $1M" {
    // Regression test for a TUI rendering bug where the per-row
    // grapheme buffer was sized at `n_cols + 12`, leaving only 10
    // cells for the right-side price label (after 2 leading spaces
    // and `n_cols` chart cells). For 13+ char money strings like
    // `$X,XXX,XXX.XX` (14 chars including the leading separator
    // space), the label got silently truncated to the 10 cell
    // budget. Buffer is now sized via `fmt.money_label_max_bytes`.

    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();

    // Synthetic candles in an arbitrary multi-million-dollar range.
    // Specific numbers don't matter; what matters is that the
    // rendered `Money.from(v).{f}` strings are wide enough to
    // exercise the label buffer (13+ chars).
    var candles: [20]zfin.Candle = undefined;
    for (0..20) |i| {
        const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
        candles[i] = .{
            .date = zfin.Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
            .open = price,
            .high = price,
            .low = price,
            .close = price,
            .adj_close = price,
            .volume = 1000,
        };
    }

    var lines: std.ArrayList(StyledLine) = .empty;
    try renderBrailleToStyledLines(a, &lines, &candles, theme.default_theme);

    // Grab the first row (which carries the max label) and the
    // last chart row (which carries the min label). Walk the
    // grapheme cells looking for the `$` and the closing digit
    // pattern of the money string.
    try testing.expect(lines.items.len >= 2);
    const first_row = lines.items[0];
    const graphemes = first_row.graphemes orelse return error.MissingGraphemes;

    // Reconstruct the rendered string and assert the full money
    // label is present (dollar sign, comma separator, two-digit
    // cents). A truncated label would lack the trailing digits.
    var rendered: std.ArrayList(u8) = .empty;
    defer rendered.deinit(a);
    for (graphemes) |g| try rendered.appendSlice(a, g);

    // The max value in the candle set is the last index's price:
    // 1_234_567.89 + 19 * 500_000.0 = 10_734_567.89.
    // `Money.from(10_734_567.89).{f}` produces `$10,734,567.89`.
    // Confirm the label as a whole substring is present (no
    // truncation).
    try testing.expect(std.mem.indexOf(u8, rendered.items, "$") != null);
    try testing.expect(std.mem.indexOf(u8, rendered.items, ".89") != null);
    try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != null);
}

// ── overlay state transitions ─────────────────────────────────
//
// The `toggleOverlay` semantics:
//   - empty `sym` argument         → close overlay (no-op when closed).
//   - overlay closed, non-empty    → open with sym.
//   - overlay open, same sym       → close.
//   - overlay open, different sym  → re-target to sym.
//
// We test it by manipulating App fields directly. App.toggleOverlay
// only reads/writes `overlay_symbol` and `overlay_symbol_buf`, so
// we don't need a fully-wired App.

test "toggleOverlay: opens when closed and sym is non-empty" {
    var app: App = undefined;
    app.overlay_symbol = "";
    app.toggleOverlay("VTI");
    try testing.expectEqualStrings("VTI", app.overlay_symbol);
}

test "toggleOverlay: closes when same sym is passed twice" {
    var app: App = undefined;
    app.overlay_symbol = "";
    app.toggleOverlay("VTI");
    app.toggleOverlay("VTI");
    try testing.expectEqualStrings("", app.overlay_symbol);
}

test "toggleOverlay: re-targets when sym differs" {
    var app: App = undefined;
    app.overlay_symbol = "";
    app.toggleOverlay("VTI");
    app.toggleOverlay("BND");
    try testing.expectEqualStrings("BND", app.overlay_symbol);
}

test "toggleOverlay: empty sym closes overlay if open, no-op when closed" {
    var app: App = undefined;
    app.overlay_symbol = "";
    app.toggleOverlay("");
    try testing.expectEqualStrings("", app.overlay_symbol);

    app.toggleOverlay("VTI");
    app.toggleOverlay("");
    try testing.expectEqualStrings("", app.overlay_symbol);
}

test "toggleOverlay: uppercases the stored symbol" {
    var app: App = undefined;
    app.overlay_symbol = "";
    app.toggleOverlay("vti");
    try testing.expectEqualStrings("VTI", app.overlay_symbol);
}

test "toggleOverlay: same-sym match is case-insensitive" {
    var app: App = undefined;
    app.overlay_symbol = "";
    app.toggleOverlay("VTI");
    // Lowercase "vti" should match the stored "VTI" and close,
    // not re-target (which would be a needless redraw).
    app.toggleOverlay("vti");
    try testing.expectEqualStrings("", app.overlay_symbol);
}
