const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");

const App = tui.App;
const StyledLine = tui.StyledLine;
const colLabel = tui.colLabel;

// Portfolio column layout (display columns).
// Each column width includes its trailing separator space.
// prefix(4) + sym(sw+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) + gl(14+1) + weight(8) + date(13+1) + account
const prefix_cols: usize = 4;
const sw: usize = fmt.sym_col_width;

// ── Portfolio-specific types ──────────────────────────────────

/// Sortable columns in the portfolio table. Bound to the
/// `sort_col_next` / `sort_col_prev` actions; the `sort_reverse`
/// action flips the current `SortDirection`.
pub const PortfolioSortField = enum {
    symbol,
    shares,
    avg_cost,
    price,
    market_value,
    gain_loss,
    weight,
    account,

    pub fn label(self: PortfolioSortField) []const u8 {
        return switch (self) {
            .symbol => "Symbol",
            .shares => "Shares",
            .avg_cost => "Avg Cost",
            .price => "Price",
            .market_value => "Market Value",
            .gain_loss => "Gain/Loss",
            .weight => "Weight",
            .account => "Account",
        };
    }

    pub fn next(self: PortfolioSortField) ?PortfolioSortField {
        const fields = std.meta.fields(PortfolioSortField);
        const idx: usize = @intFromEnum(self);
        if (idx + 1 >= fields.len) return null;
        return @enumFromInt(idx + 1);
    }

    pub fn prev(self: PortfolioSortField) ?PortfolioSortField {
        const idx: usize = @intFromEnum(self);
        if (idx == 0) return null;
        return @enumFromInt(idx - 1);
    }
};

/// Sort direction for the portfolio table.
pub const SortDirection = enum {
    asc,
    desc,

    pub fn flip(self: SortDirection) SortDirection {
        return if (self == .asc) .desc else .asc;
    }

    pub fn indicator(self: SortDirection) []const u8 {
        return if (self == .asc) "▲" else "▼";
    }
};

/// One row in the portfolio table's flattened display list.
/// Covers position rows, lot rows (when expanded), watchlist
/// entries, section headers, options/CDs/cash/illiquid summary
/// rows, and DRIP-summary rows. Rebuilt by
/// `rebuildPortfolioRows` whenever sort / filter / expansion
/// changes.
pub const PortfolioRow = struct {
    kind: Kind,
    symbol: []const u8,
    /// For position rows: index into allocations; for lot rows: lot data.
    pos_idx: usize = 0,
    lot: ?zfin.Lot = null,
    /// Number of lots for this symbol (set on position rows)
    lot_count: usize = 0,
    /// DRIP summary data (for drip_summary rows)
    drip_is_lt: bool = false, // true = LT summary, false = ST summary
    drip_lot_count: usize = 0,
    drip_shares: f64 = 0,
    drip_avg_cost: f64 = 0,
    drip_date_first: ?zfin.Date = null,
    drip_date_last: ?zfin.Date = null,
    /// Pre-formatted text from view model (options and CDs)
    prepared_text: ?[]const u8 = null,
    /// Semantic styles from view model
    row_style: fmt.StyleIntent = .normal,
    premium_style: fmt.StyleIntent = .normal,
    /// Column offset for premium alt-style coloring (options only)
    premium_col_start: usize = 0,

    const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, options_expired_rollup, cds_expired_rollup, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};

// ── Tab-local action enum ─────────────────────────────────────
//
// Portfolio tab keybinds (today routed through legacy global
// `keybinds.Action` variants — these tab-local declarations
// become authoritative when scoped keymaps land):
//   - Enter        : expand/collapse position at cursor
//   - `s` / space  : select cursor row's symbol as active
//   - `S` / `;`    : sort columns (next/prev)
//   - `o`          : reverse sort direction
//   - `a`          : open account picker

pub const Action = enum {
    /// Expand/collapse the position at the cursor row.
    expand_collapse,
    /// Move the cursor to the next sortable column.
    sort_col_next,
    /// Move the cursor to the previous sortable column.
    sort_col_prev,
    /// Flip the current sort direction (asc ↔ desc).
    sort_reverse,
    /// Open the account picker modal (state.modal = .account_picker).
    /// No-op if no portfolio is loaded.
    open_account_picker,
    /// Clear the active account filter (return to "all accounts").
    /// No-op when no filter is active. Bound to Esc.
    clear_account_filter,
    /// Select the cursor row's symbol as the currently-active
    /// symbol for the per-symbol tabs (quote/perf/options/etc.).
    select_symbol,
    /// Toggle the symbol-info overlay popup. Open at cursor
    /// row's symbol when closed; close when open and cursor is
    /// on the same symbol; re-target when open and cursor is on
    /// a different symbol.
    toggle_overlay,
};

// ── Tab-private state ─────────────────────────────────────────

/// Tab-internal modal sub-state. The picker is "modal" only from
/// the portfolio tab's perspective — App treats the tab the same
/// as any other; the tab itself swallows input and re-routes
/// drawing while a modal is active. App.Mode does NOT carry
/// these variants.
pub const Modal = enum {
    /// No modal active. Normal portfolio behavior (table view +
    /// keymap actions).
    none,
    /// Account-picker overlay open. Picker keys (j/k/Enter/Esc/...)
    /// are routed to `handleAccountPickerKey`; everything else is
    /// swallowed so global actions don't fire underneath.
    account_picker,
    /// Search-within-picker active (entered from picker via `/`).
    /// Routed to `handleAccountSearchKey`.
    account_search,
};

pub const State = struct {
    /// Selected row in the portfolio view.
    cursor: usize = 0,
    /// Per-position expansion flags. Indices align with
    /// `app.portfolio.summary.allocations`. Fixed-size; positions
    /// beyond index 64 are non-expandable in the UI.
    expanded: [64]bool = @splat(false),
    /// Whether the cash section is expanded to show per-account
    /// balances.
    cash_expanded: bool = false,
    /// Whether the illiquid section is expanded to show
    /// per-asset rows.
    illiquid_expanded: bool = false,
    /// Whether the Options section's rolled-up expired contracts
    /// are expanded to individual (muted) rows.
    options_expired_expanded: bool = false,
    /// Whether the CDs section's rolled-up matured CDs are expanded
    /// to individual (muted) rows.
    cds_expired_expanded: bool = false,
    /// Flat list of styled rows for the current view, rebuilt by
    /// `rebuildPortfolioRows` whenever sort / filter / expansion
    /// changes. Owned by State.
    rows: std.ArrayList(PortfolioRow) = .empty,
    /// Pre-computed Options section, when the portfolio holds
    /// option positions. Owned by State.
    prepared_options: ?views.Options = null,
    /// Pre-computed CDs section, when the portfolio holds CDs.
    /// Owned by State.
    prepared_cds: ?views.CDs = null,
    /// Number of styled lines before the first data row (header,
    /// totals, etc.). Used by mouse hit-tests and cursor-visibility
    /// math.
    header_lines: usize = 0,
    /// Maps styled-line index → row index in `rows`. Sized to a
    /// fixed cap; `line_count` is the live extent.
    line_to_row: [256]usize = @splat(0),
    /// Total styled lines in the portfolio view (sum of header
    /// + per-row lines).
    line_count: usize = 0,
    /// Current sort column.
    sort_field: PortfolioSortField = .symbol,
    /// Current sort direction (default: asc for symbol/account,
    /// desc for numeric columns).
    sort_dir: SortDirection = .asc,
    /// Active account filter (owned copy; null = all accounts).
    account_filter: ?[]const u8 = null,
    /// Cached positions for the active account filter, computed
    /// on filter change so subsequent renders don't re-iterate
    /// the lots list.
    filtered_positions: ?[]zfin.Position = null,
    /// Distinct accounts from the portfolio's lots, ordered as
    /// they appear in `accounts.srf` (or first-seen if no
    /// accounts.srf).
    account_list: std.ArrayList([]const u8) = .empty,
    /// Account number from `accounts.srf` (parallel to
    /// `account_list`). Null entries mean "no account number".
    account_numbers: std.ArrayList(?[]const u8) = .empty,
    /// Auto-assigned shortcut-key per account, parallel to
    /// `account_list`. Used by the account picker modal.
    account_shortcut_keys: std.ArrayList(u8) = .empty,
    // ── Account picker / search modal ──
    //
    // The portfolio tab owns picker state in full: the cursor,
    // search buffer, and the modal sub-state itself
    // (`state.modal`). The picker is "modal" only from
    // portfolio's perspective — the framework treats the tab the
    // same as any other; portfolio's own `handleKey` /
    // `handleMouse` / `drawContent` / `drawStatusBar` check
    // `state.modal` and route accordingly. App.Mode does NOT
    // carry picker variants.

    /// Cursor position in the picker (0 = "All accounts", N = nth
    /// account in `account_list` shifted by 1).
    account_picker_cursor: usize = 0,
    /// Search-mode input buffer (active when
    /// `state.modal == .account_search`).
    // SAFETY: paired with `account_search_len`; only the prefix
    // `account_search_buf[0..account_search_len]` is ever read.
    account_search_buf: [64]u8 = undefined,
    /// Live length of `account_search_buf`.
    account_search_len: usize = 0,
    /// Indices into `account_list` that match the current search
    /// query.
    account_search_matches: std.ArrayList(usize) = .empty,
    /// Cursor within `account_search_matches` (which match is
    /// currently highlighted).
    account_search_cursor: usize = 0,

    /// Tab-internal modal sub-state (account picker / search).
    /// See `Modal` for variants. App's draw / event dispatchers
    /// remain mode-agnostic; portfolio's own `handleKey` /
    /// `handleMouse` / `drawContent` / `drawStatusBar` check
    /// this and route accordingly.
    modal: Modal = .none,
};

// ── Tab framework contract ────────────────────────────────────

pub const meta: framework.TabMeta(Action) = .{
    .label = "Portfolio",
    .default_bindings = &.{
        .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
        .{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
        .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
        .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
        .{ .action = .open_account_picker, .key = .{ .codepoint = 'a' } },
        .{ .action = .clear_account_filter, .key = .{ .codepoint = vaxis.Key.escape } },
        .{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
        .{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
        // Capital K: Vim-style "what is this" toggle. Opens
        // (or re-targets) the symbol-info overlay for the
        // cursor row's symbol.
        .{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } },
    },
    .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
        .expand_collapse = "Expand/collapse position",
        .sort_col_next = "Sort: next column",
        .sort_col_prev = "Sort: previous column",
        .sort_reverse = "Sort: reverse direction",
        .open_account_picker = "Filter by account",
        .clear_account_filter = "Clear account filter",
        .select_symbol = "Select symbol",
        .toggle_overlay = "Show symbol details",
    }),
    .status_hints = &.{
        .sort_col_prev,
        .sort_col_next,
        .sort_reverse,
        .open_account_picker,
        .toggle_overlay,
    },
};

pub const tab = struct {
    pub const ActionT = Action;
    pub const StateT = State;

    pub fn init(state: *State, app: *App) !void {
        _ = app;
        state.* = .{};
    }

    pub fn deinit(state: *State, app: *App) void {
        state.rows.deinit(app.allocator);
        if (state.prepared_options) |*opts| opts.deinit();
        if (state.prepared_cds) |*cds| cds.deinit();
        if (state.account_filter) |af| app.allocator.free(af);
        if (state.filtered_positions) |fp| app.allocator.free(fp);
        state.account_list.deinit(app.allocator);
        state.account_numbers.deinit(app.allocator);
        state.account_shortcut_keys.deinit(app.allocator);
        state.account_search_matches.deinit(app.allocator);
        state.* = .{};
    }

    pub fn activate(state: *State, app: *App) !void {
        // `loadPortfolioData` calls `ensurePortfolioDataLoaded`
        // (idempotent — short-circuits when data is already
        // loaded) and then unconditionally rebuilds the
        // portfolio-tab UI state (sort, account list, rows).
        // Skipping the UI rebuild on cache hit would leave a
        // freshly-activated tab with no rows when the data was
        // pre-loaded by App startup or another tab.
        loadPortfolioData(state, app);
    }

    pub const deactivate = framework.noopDeactivate(State);

    /// Manual refresh (r/F5): re-value the portfolio with live
    /// intraday quotes, then rebuild the summary. `r` means "give me
    /// current prices now" — it does NOT force candle work. Candle
    /// history is maintained by the TTL/startup path; the reload below
    /// passes `force_refresh = false`, so candles only top up if their
    /// TTL is stale. (A full candle re-download is `cache clear`'s
    /// job; an incremental TTL-ignoring top-up is `--refresh-data=force`.)
    /// Distinct from `reloadPortfolioFile` (R), which re-reads the
    /// portfolio file from disk.
    pub fn reload(state: *State, app: *App) !void {
        // Arena for the symbol strings we hand to the live-quote fetch
        // and the price overlay. Held symbols MUST be duped: the reload
        // below deinits the old portfolio before re-parsing, and the
        // live overlay runs after that — borrowing the old portfolio's
        // strings would dangle. Freed once reload + render complete.
        var arena = std.heap.ArenaAllocator.init(app.allocator);
        defer arena.deinit();
        const a = arena.allocator();

        // Watchlist symbols (separate `watchlist.srf`) are owned by
        // `app` and stable across the reload, so we borrow them for the
        // `watchlist_syms` arg. Portfolio's own `watch` lots are picked
        // up by pd.load via the parsed file.
        var watch_syms: std.ArrayList([]const u8) = .empty;
        if (app.watchlist) |wl| {
            for (wl) |sym| watch_syms.append(a, sym) catch |err| std.log.debug("watch_syms append: {t}", .{err});
        }

        // Symbols to quote live = watchlist + held stock symbols. Held
        // symbols are duped into the arena (see above).
        var quote_syms: std.ArrayList([]const u8) = .empty;
        for (watch_syms.items) |sym| quote_syms.append(a, sym) catch |err| std.log.debug("quote_syms append: {t}", .{err});
        if (app.portfolio.file) |pf| {
            if (pf.stockSymbols(app.allocator)) |hs| {
                defer app.allocator.free(hs); // outer slice; strings duped into arena
                for (hs) |sym| {
                    const dup = a.dupe(u8, sym) catch continue;
                    quote_syms.append(a, dup) catch |err| std.log.debug("quote_syms append: {t}", .{err});
                }
            } else |err| std.log.debug("stockSymbols for live quotes: {t}", .{err});
        }

        // Parallel live-quote fetch (never cached). Symbols that fail
        // (or can't be quoted) are absent from the map → the summary
        // falls back to the candle last close for those.
        var live = app.svc.loadLiveQuotes(quote_syms.items);
        defer live.deinit();

        _ = app.portfolio.reload(app.today, .{
            .watchlist_syms = watch_syms.items,
            .live_quotes = &live,
        }) catch |err| {
            app.setStatus("Error refreshing portfolio data");
            std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err});
            return;
        };
        loadPortfolioData(state, app);
    }

    pub const tick = framework.noopTick(State);

    pub fn handleAction(state: *State, app: *App, action: Action) void {
        switch (action) {
            .expand_collapse => toggleExpandAtCursor(state, app),
            .sort_col_next => {
                if (state.sort_field.next()) |new_field| {
                    state.sort_field = new_field;
                    state.sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
                    sortPortfolioAllocations(state, app);
                    rebuildPortfolioRows(state, app);
                }
            },
            .sort_col_prev => {
                if (state.sort_field.prev()) |new_field| {
                    state.sort_field = new_field;
                    state.sort_dir = if (new_field == .symbol or new_field == .account) .asc else .desc;
                    sortPortfolioAllocations(state, app);
                    rebuildPortfolioRows(state, app);
                }
            },
            .sort_reverse => {
                state.sort_dir = state.sort_dir.flip();
                sortPortfolioAllocations(state, app);
                rebuildPortfolioRows(state, app);
            },
            .open_account_picker => {
                if (app.portfolio.file == null) return;
                state.modal = .account_picker;
                // Position cursor on the currently-active filter (or 0 for "All")
                state.account_picker_cursor = 0;
                if (state.account_filter) |af| {
                    for (state.account_list.items, 0..) |acct, ai| {
                        if (std.mem.eql(u8, acct, af)) {
                            state.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
                            break;
                        }
                    }
                }
            },
            .clear_account_filter => {
                // No-op when no filter is active.
                if (state.account_filter == null) return;
                setAccountFilter(state, app, null);
                state.cursor = 0;
                app.scroll_offset = 0;
                rebuildPortfolioRows(state, app);
                app.setStatus("Filter cleared: showing all accounts");
            },
            .select_symbol => {
                if (state.rows.items.len == 0) return;
                if (state.cursor >= state.rows.items.len) return;
                const row = state.rows.items[state.cursor];
                app.setActiveSymbol(row.symbol);
                var tmp_buf: [256]u8 = undefined;
                const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
                app.setStatus(msg);
            },
            .toggle_overlay => {
                // Resolve the cursor row's symbol; pass empty
                // string when not on a symbol row so toggleOverlay
                // closes any open overlay.
                const sym: []const u8 = blk: {
                    if (state.rows.items.len == 0) break :blk "";
                    if (state.cursor >= state.rows.items.len) break :blk "";
                    break :blk state.rows.items[state.cursor].symbol;
                };
                app.toggleOverlay(sym);
            },
        }
    }

    /// Portfolio is always enabled (the tab itself; data may be
    /// empty if no portfolio file is loaded — that's a separate
    /// concern handled by `drawWelcomeScreen`).
    pub const isDisabled = framework.alwaysEnabled();

    /// Drop UI state that referenced the previous portfolio.
    ///
    /// `account_list` holds borrowed strings into the old
    /// `Portfolio.lots`; `rows` was built against the old
    /// summary; `cursor` / `expanded` / `cash_expanded` /
    /// `illiquid_expanded` are row indices and toggles into
    /// stale data. All MUST be cleared before the new
    /// portfolio renders to avoid pointing past the end of the
    /// rebuilt list.
    ///
    /// Account filter (`state.account_filter`) is preserved
    /// because it's an owned copy of the filter NAME — the
    /// next render will re-resolve it against the rebuilt
    /// account list (and quietly drop it if the account no
    /// longer exists).
    pub fn onPortfolioReload(state: *State, app: *App) void {
        state.account_list.clearRetainingCapacity();
        state.rows.clearRetainingCapacity();
        state.expanded = @splat(false);
        state.cash_expanded = false;
        state.illiquid_expanded = false;
        state.options_expired_expanded = false;
        state.cds_expired_expanded = false;
        state.cursor = 0;
        app.scroll_offset = 0;
    }

    /// Sync the cursor to the new scroll extreme.
    pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void {
        _ = app;
        switch (where) {
            .top => state.cursor = 0,
            .bottom => {
                if (state.rows.items.len > 0) {
                    state.cursor = state.rows.items.len - 1;
                }
            },
        }
    }

    /// Step the cursor by one row. Returns false when there are
    /// no rows so the framework falls through to viewport scroll.
    pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
        if (state.rows.items.len == 0) return false;
        if (delta > 0) {
            if (state.cursor < state.rows.items.len - 1) state.cursor += 1;
        } else {
            if (state.cursor > 0) state.cursor -= 1;
        }
        ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
        return true;
    }

    // No `onWheelMove`: by omitting it, wheel events fall through
    // to the framework's `onCursorMove` path. Wheel moves the
    // cursor like j/k. PageUp / PageDown / Home / End scroll the
    // viewport.

    /// Pre-empt key handler. Called by the framework BEFORE
    /// global keymap matching runs. When portfolio is in a
    /// modal sub-state (`state.modal != .none`) we route to the
    /// modal's key handler and consume the event so global
    /// actions (refresh, tab switch, etc) don't fire underneath.
    /// When not in a modal, we return `false` so dispatch falls
    /// through to the normal global → tab-local path.
    pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool {
        return switch (state.modal) {
            .none => false,
            .account_picker => handleAccountPickerKey(state, app, key),
            .account_search => handleAccountSearchKey(state, app, key),
        };
    }

    /// Status-bar override. The picker and search modals get
    /// their own hint line; otherwise the App-level default
    /// status applies.
    pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
        _ = app;
        return switch (state.modal) {
            .none => null,
            .account_picker => .{ .hint = " j/k=navigate  Enter=select  Esc=cancel  /=search  Click=select " },
            .account_search => .{ .hint = " type to filter  Enter=select  Esc=cancel  Ctrl+N/Ctrl+P=cycle " },
        };
    }

    /// Mouse handling. In account-picker mode, drives the modal
    /// (wheel scroll, click-to-select). Otherwise: clicks on the
    /// column-header row sort by that column; clicks on a data
    /// row move the cursor and toggle expand/collapse. Returns
    /// `true` if consumed.
    pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
        // Account picker modal — swallows all mouse events when
        // active, regardless of where they land. This includes
        // tab-bar clicks (row 0): the modal blocks tab switching
        // until dismissed.
        if (state.modal != .none) {
            return handleAccountPickerMouse(state, app, mouse);
        }

        if (mouse.button != .left) return false;
        if (mouse.type != .press) return false;
        const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;

        // Click on the column-header row → sort by that column.
        if (state.header_lines > 0 and content_row == state.header_lines - 1) {
            const col: usize = @intCast(mouse.col);
            const new_field: ?PortfolioSortField =
                if (col < col_end_symbol)
                    .symbol
                else if (col < col_end_shares)
                    .shares
                else if (col < col_end_avg_cost)
                    .avg_cost
                else if (col < col_end_price)
                    .price
                else if (col < col_end_market_value)
                    .market_value
                else if (col < col_end_gain_loss)
                    .gain_loss
                else if (col < col_end_weight)
                    .weight
                else if (col < col_end_date)
                    null // Date (not sortable)
                else
                    .account;
            if (new_field) |nf| {
                if (nf == state.sort_field) {
                    state.sort_dir = state.sort_dir.flip();
                } else {
                    state.sort_field = nf;
                    state.sort_dir = if (nf == .symbol or nf == .account) .asc else .desc;
                }
                sortPortfolioAllocations(state, app);
                rebuildPortfolioRows(state, app);
                return true;
            }
            return false;
        }

        // Click on a data row → move cursor + toggle expand.
        if (content_row >= state.header_lines and state.rows.items.len > 0) {
            const line_idx = content_row - state.header_lines;
            if (line_idx < state.line_count and line_idx < state.line_to_row.len) {
                const row_idx = state.line_to_row[line_idx];
                if (row_idx < state.rows.items.len) {
                    state.cursor = row_idx;
                    toggleExpandAtCursor(state, app);
                    return true;
                }
            }
        }
        return false;
    }
};

/// Adjust `scroll_offset` so the cursor row (`state.cursor +
/// state.header_lines`) is visible within `visible_height`. Pure
/// over (state, scroll_offset, visible_height); mutates
/// `scroll_offset` only.
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
    const cursor_row = state.cursor + state.header_lines;
    if (cursor_row < scroll_offset.*) {
        scroll_offset.* = cursor_row;
    }
    const vis: usize = visible_height;
    if (vis > 0 and cursor_row >= scroll_offset.* + vis) {
        scroll_offset.* = cursor_row - vis + 1;
    }
}

fn toggleExpandAtCursor(state: *State, app: *App) void {
    if (state.rows.items.len == 0) return;
    if (state.cursor >= state.rows.items.len) return;
    const row = state.rows.items[state.cursor];
    switch (row.kind) {
        .position => {
            // Single-lot positions don't expand
            if (row.lot_count <= 1) return;
            if (row.pos_idx < state.expanded.len) {
                state.expanded[row.pos_idx] = !state.expanded[row.pos_idx];
                rebuildPortfolioRows(state, app);
            }
        },
        .lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {},
        .options_expired_rollup => {
            state.options_expired_expanded = !state.options_expired_expanded;
            rebuildPortfolioRows(state, app);
        },
        .cds_expired_rollup => {
            state.cds_expired_expanded = !state.cds_expired_expanded;
            rebuildPortfolioRows(state, app);
        },
        .cash_total => {
            state.cash_expanded = !state.cash_expanded;
            rebuildPortfolioRows(state, app);
        },
        .illiquid_total => {
            state.illiquid_expanded = !state.illiquid_expanded;
            rebuildPortfolioRows(state, app);
        },
        .watchlist => {
            app.setActiveSymbol(row.symbol);
            app.active_tab = .quote;
            app.loadTabData();
        },
    }
}

/// Cumulative column end positions for click-to-sort hit testing.
pub const col_end_symbol: usize = prefix_cols + sw + 1;
pub const col_end_shares: usize = col_end_symbol + 9;
pub const col_end_avg_cost: usize = col_end_shares + 11;
pub const col_end_price: usize = col_end_avg_cost + 11;
pub const col_end_market_value: usize = col_end_price + 17;
pub const col_end_gain_loss: usize = col_end_market_value + 15;
pub const col_end_weight: usize = col_end_gain_loss + 9;
pub const col_end_date: usize = col_end_weight + 14;

// Gain/loss column start position (used for alt-style coloring)
const gl_col_start: usize = col_end_market_value;

/// Map a semantic StyleIntent to a platform-specific vaxis style.
fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style {
    return th.styleFor(intent);
}

// ── Data loading ──────────────────────────────────────────────

/// Load portfolio data: prices, summary, candle map, and historical snapshots.
///
/// Call paths:
///   1. First tab visit: loadTabData() → here (guarded by portfolio_loaded flag)
///   2. Manual refresh (r/F5): refreshCurrentTab() clears portfolio_loaded → loadTabData() → here
///   3. Disk reload (R): reloadPortfolioFile() — separate function, cache-only, no network
///
/// Tab switching is a no-op when portfolio.summary is already
/// populated; the row build is cheap so re-running it on a
/// re-activate has no measurable cost.
/// Set up the portfolio tab's UI state from current
/// `app.portfolio` data: sort allocations per current sort
/// field, build the account list, recompute filtered positions
/// (when an account filter is active), and rebuild the styled
/// row list. Called from `activate` AFTER
/// `app.ensurePortfolioDataLoaded()`. No-op when no summary is
/// available.
///
/// Call paths:
///   1. First tab visit: `tab.activate` → here
///   2. Manual refresh (r/F5): `tab.reload` clears
///      `app.portfolio.loaded` → `tab.activate` → ensurePortfolioDataLoaded → here
///   3. Disk reload (R): `reloadPortfolioFile` — separate
///      function, cache-only, no network
///
/// Tab switching skips this entirely because `tab.activate`'s
/// own guard short-circuits when `state.loaded` (TODO: this
/// flag doesn't exist yet on portfolio.State; today the guard
/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded`
/// owns. Visiting portfolio after analysis pre-loaded the data
/// will still rebuild the row list — cheap.)
pub fn loadPortfolioData(state: *State, app: *App) void {
    // Summary is populated synchronously by pd.load; if it's
    // null here, no portfolio is loaded (welcome screen).
    const summary = app.portfolio.summary orelse return;

    sortPortfolioAllocations(state, app);
    buildAccountList(state, app);
    recomputeFilteredPositions(state, app);
    rebuildPortfolioRows(state, app);

    // Pre-select the first row when no symbol is active yet.
    // Runs AFTER `sortPortfolioAllocations` so the default
    // matches what the user sees at the top of the table —
    // alphabetically first by symbol with the default sort,
    // not whatever lot happens to appear first in
    // `portfolio.srf`. This is the "user just started the TUI;
    // pick something sensible" path; once `app.symbol` is set
    // (by user action or `--symbol`), this is a no-op.
    if (app.symbol.len == 0 and summary.allocations.len > 0) {
        app.setActiveSymbol(summary.allocations[0].symbol);
    }
}

pub fn sortPortfolioAllocations(state: *State, app: *App) void {
    if (app.portfolio.summary) |s| {
        const SortCtx = struct {
            field: PortfolioSortField,
            dir: SortDirection,

            fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
                const lhs = if (ctx.dir == .asc) a else b;
                const rhs = if (ctx.dir == .asc) b else a;
                return switch (ctx.field) {
                    .symbol => std.mem.lessThan(u8, lhs.display_symbol, rhs.display_symbol),
                    .shares => lhs.shares < rhs.shares,
                    .avg_cost => lhs.avg_cost < rhs.avg_cost,
                    .price => lhs.current_price < rhs.current_price,
                    .market_value => lhs.market_value < rhs.market_value,
                    .gain_loss => lhs.unrealized_gain_loss < rhs.unrealized_gain_loss,
                    .weight => lhs.weight < rhs.weight,
                    .account => std.mem.lessThan(u8, lhs.account, rhs.account),
                };
            }
        };
        std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = state.sort_field, .dir = state.sort_dir }, SortCtx.lessThan);
    }
}

pub fn rebuildPortfolioRows(state: *State, app: *App) void {
    rebuildPortfolioRowsImpl(state, app) catch |err| {
        // OOM building the row list. Render path will see a
        // possibly-truncated list of rows; the next event will
        // try again.
        std.log.debug("rebuildPortfolioRows failed: {t}", .{err});
    };
}

fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
    state.rows.clearRetainingCapacity();
    if (state.prepared_options) |*opts| opts.deinit();
    state.prepared_options = null;
    if (state.prepared_cds) |*cds| cds.deinit();
    state.prepared_cds = null;

    if (app.portfolio.summary) |s| {
        for (s.allocations, 0..) |a, i| {
            // Skip allocations that don't match account filter
            if (!allocationMatchesFilter(state, a)) continue;

            // Count lots for this symbol (filtered by account when filter is active)
            var lcount: usize = 0;
            if (state.filtered_positions) |fps| {
                for (fps) |pos| {
                    if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) {
                        lcount += pos.open_lots + pos.closed_lots;
                    }
                }
            } else if (state.account_filter == null) {
                if (app.portfolio.file) |pf| {
                    for (pf.lots) |lot| {
                        if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
                            lcount += 1;
                        }
                    }
                }
            }

            state.rows.append(app.allocator, .{
                .kind = .position,
                .symbol = a.symbol,
                .pos_idx = i,
                .lot_count = lcount,
            }) catch continue;

            // Only expand if multi-lot
            if (lcount > 1 and i < state.expanded.len and state.expanded[i]) {
                if (app.portfolio.file) |pf| {
                    // Collect matching lots, sort: open first (date desc), then closed (date desc)
                    var matching: std.ArrayList(zfin.Lot) = .empty;
                    defer matching.deinit(app.allocator);
                    for (pf.lots) |lot| {
                        if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
                            if (matchesAccountFilter(state, lot.account))
                                matching.append(app.allocator, lot) catch continue;
                        }
                    }
                    std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);

                    // Check if any lots are DRIP
                    var has_drip = false;
                    for (matching.items) |lot| {
                        if (lot.drip) {
                            has_drip = true;
                            break;
                        }
                    }

                    if (!has_drip) {
                        // No DRIP lots: show all individually
                        for (matching.items) |lot| {
                            state.rows.append(app.allocator, .{
                                .kind = .lot,
                                .symbol = lot.symbol,
                                .pos_idx = i,
                                .lot = lot,
                            }) catch continue;
                        }
                    } else {
                        // Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
                        for (matching.items) |lot| {
                            if (!lot.drip) {
                                state.rows.append(app.allocator, .{
                                    .kind = .lot,
                                    .symbol = lot.symbol,
                                    .pos_idx = i,
                                    .lot = lot,
                                }) catch continue;
                            }
                        }

                        // Build ST and LT DRIP summaries
                        const drip = fmt.aggregateDripLots(app.today, matching.items);

                        if (!drip.st.isEmpty()) {
                            try state.rows.append(app.allocator, .{
                                .kind = .drip_summary,
                                .symbol = a.symbol,
                                .pos_idx = i,
                                .drip_is_lt = false,
                                .drip_lot_count = drip.st.lot_count,
                                .drip_shares = drip.st.shares,
                                .drip_avg_cost = drip.st.avgCost(),
                                .drip_date_first = drip.st.first_date,
                                .drip_date_last = drip.st.last_date,
                            });
                        }
                        if (!drip.lt.isEmpty()) {
                            try state.rows.append(app.allocator, .{
                                .kind = .drip_summary,
                                .symbol = a.symbol,
                                .pos_idx = i,
                                .drip_is_lt = true,
                                .drip_lot_count = drip.lt.lot_count,
                                .drip_shares = drip.lt.shares,
                                .drip_avg_cost = drip.lt.avgCost(),
                                .drip_date_first = drip.lt.first_date,
                                .drip_date_last = drip.lt.last_date,
                            });
                        }
                    }
                }
            }
        }
    }

    // Add watchlist items from both the separate watchlist file and
    // watch lots embedded in the portfolio. Skip symbols already in allocations.
    // Hide watchlist entirely when account filter is active (watchlist items don't belong to accounts).
    if (state.account_filter == null) {
        var watch_seen = std.StringHashMap(void).init(app.allocator);
        defer watch_seen.deinit();

        // Mark all portfolio position symbols as seen
        if (app.portfolio.summary) |s| {
            for (s.allocations) |a| {
                try watch_seen.put(a.symbol, {});
            }
        }

        // Watch lots from portfolio file
        if (app.portfolio.file) |pf| {
            for (pf.lots) |lot| {
                if (lot.security_type == .watch) {
                    if (watch_seen.contains(lot.priceSymbol())) continue;
                    try watch_seen.put(lot.priceSymbol(), {});
                    state.rows.append(app.allocator, .{
                        .kind = .watchlist,
                        .symbol = lot.symbol,
                    }) catch continue;
                }
            }
        }

        // Separate watchlist file (backward compat)
        if (app.watchlist) |wl| {
            for (wl) |sym| {
                if (watch_seen.contains(sym)) continue;
                try watch_seen.put(sym, {});
                state.rows.append(app.allocator, .{
                    .kind = .watchlist,
                    .symbol = sym,
                }) catch continue;
            }
        }
    }

    // Options section (sorted by expiration date, then symbol; filtered by account)
    if (app.portfolio.file) |pf| {
        state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
        if (state.prepared_options) |opts| {
            const active = opts.activeItems();
            const expired = opts.expiredItems();
            if (active.len > 0 or expired.len > 0) {
                try state.rows.append(app.allocator, .{
                    .kind = .section_header,
                    .symbol = "Options",
                });
                for (active) |po| {
                    state.rows.append(app.allocator, .{
                        .kind = .option_row,
                        .symbol = po.lot.symbol,
                        .lot = po.lot,
                        .prepared_text = po.columns[0].text,
                        .row_style = po.row_style,
                        .premium_style = po.premium_style,
                        .premium_col_start = po.premium_col_start,
                    }) catch continue;
                }
                // Expired contracts: a rolled-up summary row, expandable
                // into individual (muted) rows.
                if (expired.len > 0) {
                    try state.rows.append(app.allocator, .{
                        .kind = .options_expired_rollup,
                        .symbol = "Options",
                    });
                    if (state.options_expired_expanded) {
                        for (expired) |po| {
                            state.rows.append(app.allocator, .{
                                .kind = .option_row,
                                .symbol = po.lot.symbol,
                                .lot = po.lot,
                                .prepared_text = po.columns[0].text,
                                .row_style = po.row_style,
                                .premium_style = po.premium_style,
                                .premium_col_start = po.premium_col_start,
                            }) catch continue;
                        }
                    }
                }
            }
        }

        // CDs section (sorted by maturity date, earliest first; filtered by account)
        state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null;
        if (state.prepared_cds) |cds| {
            const active = cds.activeItems();
            const expired = cds.expiredItems();
            if (active.len > 0 or expired.len > 0) {
                try state.rows.append(app.allocator, .{
                    .kind = .section_header,
                    .symbol = "Certificates of Deposit",
                });
                for (active) |pc| {
                    state.rows.append(app.allocator, .{
                        .kind = .cd_row,
                        .symbol = pc.lot.symbol,
                        .lot = pc.lot,
                        .prepared_text = pc.text,
                        .row_style = pc.row_style,
                    }) catch continue;
                }
                // Matured CDs: a rolled-up summary row, expandable into
                // individual (muted) rows.
                if (expired.len > 0) {
                    try state.rows.append(app.allocator, .{
                        .kind = .cds_expired_rollup,
                        .symbol = "Certificates of Deposit",
                    });
                    if (state.cds_expired_expanded) {
                        for (expired) |pc| {
                            state.rows.append(app.allocator, .{
                                .kind = .cd_row,
                                .symbol = pc.lot.symbol,
                                .lot = pc.lot,
                                .prepared_text = pc.text,
                                .row_style = pc.row_style,
                            }) catch continue;
                        }
                    }
                }
            }
        }

        // Cash section (filtered by account when filter is active)
        if (pf.hasType(.cash)) {
            // When filtered, only show cash lots matching the account
            if (state.account_filter != null) {
                var cash_lots: std.ArrayList(zfin.Lot) = .empty;
                defer cash_lots.deinit(app.allocator);
                for (pf.lots) |lot| {
                    if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
                        cash_lots.append(app.allocator, lot) catch continue;
                    }
                }
                if (cash_lots.items.len > 0) {
                    try state.rows.append(app.allocator, .{
                        .kind = .section_header,
                        .symbol = "Cash",
                    });
                    for (cash_lots.items) |lot| {
                        state.rows.append(app.allocator, .{
                            .kind = .cash_row,
                            .symbol = lot.account orelse "Unknown",
                            .lot = lot,
                        }) catch continue;
                    }
                }
            } else {
                // Unfiltered: show total + expandable per-account rows
                try state.rows.append(app.allocator, .{
                    .kind = .section_header,
                    .symbol = "Cash",
                });
                try state.rows.append(app.allocator, .{
                    .kind = .cash_total,
                    .symbol = "CASH",
                });
                if (state.cash_expanded) {
                    for (pf.lots) |lot| {
                        if (lot.security_type == .cash) {
                            state.rows.append(app.allocator, .{
                                .kind = .cash_row,
                                .symbol = lot.account orelse "Unknown",
                                .lot = lot,
                            }) catch continue;
                        }
                    }
                }
            }
        }

        // Illiquid assets section (hidden when account filter is active)
        if (state.account_filter == null) {
            if (pf.hasType(.illiquid)) {
                try state.rows.append(app.allocator, .{
                    .kind = .section_header,
                    .symbol = "Illiquid Assets",
                });
                try state.rows.append(app.allocator, .{
                    .kind = .illiquid_total,
                    .symbol = "ILLIQUID",
                });
                if (state.illiquid_expanded) {
                    for (pf.lots) |lot| {
                        if (lot.security_type == .illiquid) {
                            state.rows.append(app.allocator, .{
                                .kind = .illiquid_row,
                                .symbol = lot.symbol,
                                .lot = lot,
                            }) catch continue;
                        }
                    }
                }
            }
        }
    }
}

/// Set or clear the account filter on portfolio.State. Owns
/// the filter string via allocator (dup on set, free on
/// clear/replace) and recomputes `filtered_positions` from
/// `app.portfolio.file` so subsequent renders don't have to
/// re-iterate lots. Pass `null` to clear.
pub fn setAccountFilter(state: *State, app: *App, name: ?[]const u8) void {
    if (state.account_filter) |old| app.allocator.free(old);
    if (state.filtered_positions) |fp| app.allocator.free(fp);
    state.filtered_positions = null;

    if (name) |n| {
        state.account_filter = app.allocator.dupe(u8, n) catch null;
        if (app.portfolio.file) |pf| {
            state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, n) catch null;
        }
    } else {
        state.account_filter = null;
    }
}

/// Build the ordered list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
pub fn buildAccountList(state: *State, app: *App) void {
    state.account_list.clearRetainingCapacity();
    state.account_numbers.clearRetainingCapacity();
    state.account_shortcut_keys.clearRetainingCapacity();

    const pf = app.portfolio.file orelse return;

    // Collect distinct account names from portfolio lots
    var seen = std.StringHashMap(void).init(app.allocator);
    defer seen.deinit();

    var lot_accounts = std.ArrayList([]const u8).empty;
    defer lot_accounts.deinit(app.allocator);

    for (pf.lots) |lot| {
        if (lot.account) |acct| {
            if (acct.len > 0 and !seen.contains(acct)) {
                seen.put(acct, {}) catch continue;
                lot_accounts.append(app.allocator, acct) catch continue;
            }
        }
    }

    // Phase 1: add accounts in accounts.srf order (if available).
    // accountMap() blocks on its worker; first call may briefly
    // wait until the account_map worker finishes loading.
    if (app.portfolio.accountMap()) |am| {
        for (am.entries) |entry| {
            if (seen.contains(entry.account)) {
                state.account_list.append(app.allocator, entry.account) catch continue;
                state.account_numbers.append(app.allocator, entry.account_number) catch continue;
            }
        }
    }

    // Phase 2: add accounts not in accounts.srf, sorted alphabetically
    var extras = std.ArrayList([]const u8).empty;
    defer extras.deinit(app.allocator);

    for (lot_accounts.items) |acct| {
        var found = false;
        for (state.account_list.items) |existing| {
            if (std.mem.eql(u8, acct, existing)) {
                found = true;
                break;
            }
        }
        if (!found) extras.append(app.allocator, acct) catch continue;
    }

    std.mem.sort([]const u8, extras.items, {}, struct {
        fn lessThan(_: void, a: []const u8, b: []const u8) bool {
            return std.mem.lessThan(u8, a, b);
        }
    }.lessThan);

    for (extras.items) |acct| {
        state.account_list.append(app.allocator, acct) catch continue;
        state.account_numbers.append(app.allocator, null) catch continue;
    }

    // Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys)
    assignShortcutKeys(state, app);

    // If the current filter no longer exists in the new list, clear it
    if (state.account_filter) |af| {
        var found = false;
        for (state.account_list.items) |acct| {
            if (std.mem.eql(u8, acct, af)) {
                found = true;
                break;
            }
        }
        if (!found) setAccountFilter(state, app, null);
    }
}

const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz";

fn assignShortcutKeys(state: *State, app: *App) void {
    state.account_shortcut_keys.clearRetainingCapacity();
    var key_idx: usize = 0;
    for (0..state.account_list.items.len) |_| {
        if (key_idx < shortcut_key_order.len) {
            state.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
            key_idx += 1;
        } else {
            state.account_shortcut_keys.append(app.allocator, 0) catch continue;
        }
    }
}

/// Recompute filtered_positions when portfolio or account filter changes.
fn recomputeFilteredPositions(state: *State, app: *App) void {
    if (state.filtered_positions) |fp| app.allocator.free(fp);
    state.filtered_positions = null;
    const filter = state.account_filter orelse return;
    const pf = app.portfolio.file orelse return;
    state.filtered_positions = pf.positionsForAccount(app.today, app.allocator, filter) catch null;
}

/// Check if a lot matches the active account filter.
/// Returns true if no filter is active or the lot's account matches.
/// Returns true if `account` matches the active account filter.
/// When no filter is active, returns true (all accounts pass).
/// When an account is null but a filter is active, returns false.
fn matchesAccountFilter(state: *const State, account: ?[]const u8) bool {
    const filter = state.account_filter orelse return true;
    const acct = account orelse return false;
    return std.mem.eql(u8, acct, filter);
}

/// Check if an allocation matches the active account filter.
/// When filtered, checks against pre-computed filtered_positions.
fn allocationMatchesFilter(state: *const State, a: zfin.valuation.Allocation) bool {
    if (state.account_filter == null) return true;
    const fps = state.filtered_positions orelse return false;
    for (fps) |pos| {
        if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol))
            return true;
    }
    return false;
}

/// Account-filtered view of an allocation. When a position spans multiple accounts,
/// this holds the values for only the lots matching the active account filter.
const FilteredAlloc = struct {
    shares: f64,
    cost_basis: f64,
    market_value: f64,
    unrealized_gain_loss: f64,
};

/// Compute account-filtered values for an allocation.
/// For single-account positions (or no filter), returns the allocation's own values.
/// For filtered views, sums across all matching positions for the symbol.
/// This handles rolled-up allocations where multiple positions with different
/// price_ratios share the same ticker (e.g. direct SPY + institutional CIT).
fn filteredAllocValues(state: *const State, a: zfin.valuation.Allocation) FilteredAlloc {
    if (state.account_filter == null) return .{
        .shares = a.shares,
        .cost_basis = a.cost_basis,
        .market_value = a.market_value,
        .unrealized_gain_loss = a.unrealized_gain_loss,
    };
    const fps = state.filtered_positions orelse return .{
        .shares = 0,
        .cost_basis = 0,
        .market_value = 0,
        .unrealized_gain_loss = 0,
    };

    // Sum across all filtered positions matching this symbol.
    // For rolled-up allocations, the raw ticker price is used with each
    // position's own price_ratio to compute correct per-position values.
    var total_shares: f64 = 0;
    var total_cost: f64 = 0;
    var total_mv: f64 = 0;
    var found = false;

    for (fps) |pos| {
        if (std.mem.eql(u8, pos.symbol, a.symbol) or std.mem.eql(u8, pos.lot_symbol, a.symbol)) {
            found = true;
            total_shares += pos.shares * pos.price_ratio; // normalize to base units
            total_cost += pos.total_cost;
            total_mv += pos.shares * a.current_price * pos.price_ratio;
        }
    }

    if (!found) return .{
        .shares = 0,
        .cost_basis = 0,
        .market_value = 0,
        .unrealized_gain_loss = 0,
    };
    return .{
        .shares = total_shares,
        .cost_basis = total_cost,
        .market_value = total_mv,
        .unrealized_gain_loss = total_mv - total_cost,
    };
}

/// Totals for the filtered account view (stocks + cash + CDs + options).
const FilteredTotals = struct {
    value: f64,
    cost: f64,
};

/// Compute total value and cost across all asset types for the active account filter.
/// Returns {0, 0} if no filter is active.
fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
    const af = state.account_filter orelse return .{ .value = 0, .cost = 0 };
    var value: f64 = 0;
    var cost: f64 = 0;
    if (app.portfolio.summary) |s| {
        for (s.allocations) |a| {
            if (allocationMatchesFilter(state, a)) {
                const fa = filteredAllocValues(state, a);
                value += fa.market_value;
                cost += fa.cost_basis;
            }
        }
    }
    if (app.portfolio.file) |pf| {
        const ns = pf.nonStockValueForAccount(app.today, af);
        value += ns;
        cost += ns;
    }
    return .{ .value = value, .cost = cost };
}

// ── Rendering ─────────────────────────────────────────────────

pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    // Modal sub-state takes over the content surface entirely.
    // Picker overlay replaces the portfolio table while open.
    if (state.modal != .none) {
        try drawAccountPicker(state, app, arena, buf, width, height);
        return;
    }

    const th = app.theme;

    if (app.portfolio.file == null and app.watchlist == null) {
        try drawWelcomeScreen(app, arena, buf, width, height);
        return;
    }

    var lines: std.ArrayList(StyledLine) = .empty;
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    if (app.portfolio.summary) |s| {
        if (state.account_filter) |af| {
            // Filtered mode: compute account-specific totals
            const ft = computeFilteredTotals(state, app);
            const filtered_value = ft.value;
            const filtered_cost = ft.cost;
            const filtered_gl = filtered_value - filtered_cost;
            const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);

            // Account name line
            const acct_text = try std.fmt.allocPrint(arena, "  Account: {s}", .{af});
            try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() });

            const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl;
            const summary_text = try std.fmt.allocPrint(arena, "  Value: {f}  Cost: {f}  Gain/Loss: {s}{f} ({d:.1}%)", .{
                Money.from(filtered_value),
                Money.from(filtered_cost),
                if (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
                Money.from(gl_abs),
                filtered_return * 100.0,
            });
            const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
            try lines.append(arena, .{ .text = summary_text, .style = summary_style });

            if (app.portfolio.live_prices_applied) {
                // Live overlay applied: values reflect today's intraday
                // quotes, not the candle close. Static text for now; a
                // precise "as of HH:MM ET" awaits real tz handling.
                try lines.append(arena, .{ .text = "  (as of intraday quote today)", .style = th.mutedStyle() });
            } else if (app.portfolio.latest_quote_date) |d| {
                const asof_text = try std.fmt.allocPrint(arena, "  (as of close on {f})", .{d});
                try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
            }
            // No historical snapshots or net worth when filtered
        } else {
            // Unfiltered mode: use portfolio_summary totals directly
            const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
            const summary_text = try std.fmt.allocPrint(arena, "  Value: {f}  Cost: {f}  Gain/Loss: {s}{f} ({d:.1}%)", .{
                Money.from(s.total_value),
                Money.from(s.total_cost),
                if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
                Money.from(gl_abs),
                s.unrealized_return * 100.0,
            });
            const summary_style = if (s.unrealized_gain_loss >= 0) th.positiveStyle() else th.negativeStyle();
            try lines.append(arena, .{ .text = summary_text, .style = summary_style });

            // "as of" date indicator
            if (app.portfolio.live_prices_applied) {
                // Live overlay applied: values reflect today's intraday
                // quotes, not the candle close. Static text for now; a
                // precise "as of HH:MM ET" awaits real tz handling.
                try lines.append(arena, .{ .text = "  (as of intraday quote today)", .style = th.mutedStyle() });
            } else if (app.portfolio.latest_quote_date) |d| {
                const asof_text = try std.fmt.allocPrint(arena, "  (as of close on {f})", .{d});
                try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
            }

            // Net Worth line (only if portfolio has illiquid assets)
            if (app.portfolio.file) |pf| {
                if (pf.hasType(.illiquid)) {
                    const illiquid_total = pf.totalIlliquid(app.today);
                    const net_worth = zfin.valuation.netWorth(app.today, pf, s);
                    const nw_text = try std.fmt.allocPrint(arena, "  Net Worth: {f}  (Liquid: {f}  Illiquid: {f})", .{
                        Money.from(net_worth),
                        Money.from(s.total_value),
                        Money.from(illiquid_total),
                    });
                    try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
                }
            }

            // Historical portfolio value snapshots. snapshots()
            // blocks on the snapshots worker the first time it's
            // called; portfolio is the first tab the user sees,
            // so this is where the wait (if any) happens.
            if (app.portfolio.snapshots()) |snapshots| {
                try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
                var hist_parts: [6][]const u8 = undefined;
                for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
                    const snap = snapshots[pi];
                    var hbuf: [16]u8 = undefined;
                    const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
                    hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {s}", .{ period.label(), change_str });
                }
                const hist_text = try std.fmt.allocPrint(arena, "  Historical:  {s}  {s}  {s}  {s}  {s}  {s}", .{
                    hist_parts[0], hist_parts[1], hist_parts[2], hist_parts[3], hist_parts[4], hist_parts[5],
                });
                try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
            }
        }
    } else if (app.portfolio.file != null) {
        try lines.append(arena, .{ .text = "  No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
    } else {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    }

    // Empty line before header
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Column header (4-char prefix to match arrow(2)+star(2) in data rows)
    // Active sort column gets a sort indicator within the column width
    const sf = state.sort_field;
    const si = state.sort_dir.indicator();
    // Build column labels with indicator embedded in padding
    // Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
    var sym_hdr_buf: [16]u8 = undefined;
    var shr_hdr_buf: [16]u8 = undefined;
    var avg_hdr_buf: [16]u8 = undefined;
    var prc_hdr_buf: [16]u8 = undefined;
    var mv_hdr_buf: [24]u8 = undefined;
    var gl_hdr_buf: [24]u8 = undefined;
    var wt_hdr_buf: [16]u8 = undefined;
    const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null);
    const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
    const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
    const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
    const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
    const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
    const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
    const acct_ind: []const u8 = if (sf == .account) si else "";

    const hdr = try std.fmt.allocPrint(arena, "    {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
        sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account",
    });
    try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });

    // Track header line count for mouse click mapping (after all header lines)
    state.header_lines = lines.items.len;
    state.line_count = 0;

    // Compute filtered total value for account-relative weight calculation
    const filtered_total_for_weight: f64 = if (state.account_filter != null)
        computeFilteredTotals(state, app).value
    else
        0;

    // Data rows
    for (state.rows.items, 0..) |row, ri| {
        const lines_before = lines.items.len;
        const is_cursor = ri == state.cursor;
        const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
        switch (row.kind) {
            .position => {
                if (app.portfolio.summary) |s| {
                    if (row.pos_idx < s.allocations.len) {
                        const a = s.allocations[row.pos_idx];
                        // Use account-filtered values for multi-account positions
                        const fa = filteredAllocValues(state, a);
                        const display_shares = fa.shares;
                        const display_avg_cost = if (fa.shares > 0) fa.cost_basis / fa.shares else a.avg_cost;
                        const display_mv = fa.market_value;
                        const display_gl = fa.unrealized_gain_loss;

                        const is_multi = row.lot_count > 1;
                        const is_expanded = is_multi and row.pos_idx < state.expanded.len and state.expanded[row.pos_idx];
                        const arrow: []const u8 = if (!is_multi) "  " else if (is_expanded) "v " else "> ";
                        const star: []const u8 = if (is_active_sym) "* " else "  ";
                        const pnl_pct = if (fa.cost_basis > 0) (display_gl / fa.cost_basis) * 100.0 else @as(f64, 0);
                        var gl_val_buf: [24]u8 = undefined;
                        const gl_abs = if (display_gl >= 0) display_gl else -display_gl;
                        const gl_money = std.fmt.bufPrint(&gl_val_buf, "{f}", .{Money.from(gl_abs)}) catch "$?";
                        var pnl_buf: [20]u8 = undefined;
                        const pnl_str = if (display_gl >= 0)
                            std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
                        else
                            std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
                        var mv_buf: [24]u8 = undefined;
                        const mv_str = std.fmt.bufPrint(&mv_buf, "{f}", .{Money.from(display_mv)}) catch "$?";
                        var cost_buf2: [24]u8 = undefined;
                        const cost_str = std.fmt.bufPrint(&cost_buf2, "{f}", .{Money.from(display_avg_cost)}) catch "$?";
                        var price_buf2: [24]u8 = undefined;
                        const price_str = std.fmt.bufPrint(&price_buf2, "{f}", .{Money.from(a.current_price)}) catch "$?";

                        // Date + ST/LT: show for single-lot, blank for multi-lot
                        var pos_date_buf: [10]u8 = undefined;
                        var date_col: []const u8 = "";
                        var acct_col: []const u8 = "";
                        if (!is_multi) {
                            if (app.portfolio.file) |pf| {
                                for (pf.lots) |lot| {
                                    if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
                                        if (matchesAccountFilter(state, lot.account)) {
                                            const ds = std.fmt.bufPrint(&pos_date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
                                            const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date);
                                            date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
                                            acct_col = lot.account orelse "";
                                            break;
                                        }
                                    }
                                }
                            }
                        } else if (state.account_filter) |af| {
                            acct_col = af;
                        } else {
                            acct_col = a.account;
                        }

                        const display_weight = if (state.account_filter != null and filtered_total_for_weight > 0)
                            (display_mv / filtered_total_for_weight)
                        else
                            a.weight;

                        const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
                            arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col,
                        });

                        // base: neutral text for main cols, green/red only for gain/loss col
                        // Manual-price positions use warning color to indicate stale/estimated price
                        const base_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else th.contentStyle();
                        const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle();

                        try lines.append(arena, .{
                            .text = text,
                            .style = base_style,
                            .alt_style = gl_style,
                            .alt_start = gl_col_start,
                            .alt_end = gl_col_start + 14,
                        });
                    }
                }
            },
            .lot => {
                if (row.lot) |lot| {
                    var date_buf: [10]u8 = undefined;
                    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";

                    // Compute lot gain/loss and market value if we have a price
                    var lot_gl_str: []const u8 = "";
                    var lot_mv_str: []const u8 = "";
                    var lot_positive = true;
                    if (app.portfolio.summary) |s| {
                        if (row.pos_idx < s.allocations.len) {
                            const price = s.allocations[row.pos_idx].current_price;
                            const use_price = lot.close_price orelse price;
                            const gl = lot.shares * (use_price - lot.open_price);
                            lot_positive = gl >= 0;
                            lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
                                if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"),
                                Money.from(if (gl >= 0) gl else -gl),
                            });
                            lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.shares * use_price)});
                        }
                    }

                    var price_str2: [24]u8 = undefined;
                    const lot_price_str = std.fmt.bufPrint(&price_str2, "{f}", .{Money.from(lot.open_price)}) catch "$?";
                    const status_str: []const u8 = if (lot.isOpen(app.today)) "open" else "closed";
                    const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date);
                    const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
                    const acct_col: []const u8 = lot.account orelse "";
                    const text = try std.fmt.allocPrint(arena, "    " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
                        status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col,
                    });
                    const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
                    const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle();
                    try lines.append(arena, .{
                        .text = text,
                        .style = base_style,
                        .alt_style = gl_col_style,
                        .alt_start = gl_col_start,
                        .alt_end = gl_col_start + 14,
                    });
                }
            },
            .watchlist => {
                var price_str3: [16]u8 = undefined;
                const ps: []const u8 = if (app.portfolio.watchlist_prices) |wp|
                    (if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--")
                else
                    "--";
                const star2: []const u8 = if (is_active_sym) "* " else "  ";
                const text = try std.fmt.allocPrint(arena, "  {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
                    star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
                });
                const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
                try lines.append(arena, .{ .text = text, .style = row_style });
            },
            .section_header => {
                // Blank line before section header
                try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
                const hdr_text = try std.fmt.allocPrint(arena, "  {s}", .{row.symbol});
                const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle();
                try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
                // Add column headers for each section type
                if (std.mem.eql(u8, row.symbol, "Options")) {
                    const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels);
                    try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
                } else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
                    const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels);
                    try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
                }
            },
            .option_row => {
                if (row.prepared_text) |text| {
                    const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
                    const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style);
                    try lines.append(arena, .{
                        .text = text,
                        .style = row_style2,
                        .alt_style = prem_style,
                        .alt_start = row.premium_col_start,
                        .alt_end = row.premium_col_start + 14,
                    });
                }
            },
            .cd_row => {
                if (row.prepared_text) |text| {
                    const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
                    try lines.append(arena, .{ .text = text, .style = row_style3 });
                }
            },
            .options_expired_rollup => {
                if (state.prepared_options) |opts| {
                    const n = opts.expiredItems().len;
                    const arrow: []const u8 = if (state.options_expired_expanded) "v " else "> ";
                    const noun: []const u8 = if (n == 1) "expired option" else "expired options";
                    const text = try std.fmt.allocPrint(arena, "  {s}{d} {s}", .{ arrow, n, noun });
                    const rollup_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
                    try lines.append(arena, .{ .text = text, .style = rollup_style });
                }
            },
            .cds_expired_rollup => {
                if (state.prepared_cds) |cds| {
                    const n = cds.expiredItems().len;
                    const arrow: []const u8 = if (state.cds_expired_expanded) "v " else "> ";
                    const noun: []const u8 = if (n == 1) "matured CD" else "matured CDs";
                    const text = try std.fmt.allocPrint(arena, "  {s}{d} {s}", .{ arrow, n, noun });
                    const rollup_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
                    try lines.append(arena, .{ .text = text, .style = rollup_style });
                }
            },
            .cash_total => {
                if (app.portfolio.file) |pf| {
                    const total_cash = pf.totalCash(app.today);
                    const arrow3: []const u8 = if (state.cash_expanded) "v " else "> ";
                    const text = try std.fmt.allocPrint(arena, "  {s}Total Cash {f}", .{
                        arrow3,
                        Money.from(total_cash).padRight(14),
                    });
                    const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
                    try lines.append(arena, .{ .text = text, .style = row_style4 });
                }
            },
            .cash_row => {
                if (row.lot) |lot| {
                    var cash_row_buf: [160]u8 = undefined;
                    const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares, lot.note);
                    const text = try std.fmt.allocPrint(arena, "    {s}", .{row_text});
                    const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
                    try lines.append(arena, .{ .text = text, .style = row_style5 });
                }
            },
            .illiquid_total => {
                if (app.portfolio.file) |pf| {
                    const total_illiquid = pf.totalIlliquid(app.today);
                    const arrow4: []const u8 = if (state.illiquid_expanded) "v " else "> ";
                    const text = try std.fmt.allocPrint(arena, "  {s}Total Illiquid {f}", .{
                        arrow4,
                        Money.from(total_illiquid).padRight(14),
                    });
                    const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle();
                    try lines.append(arena, .{ .text = text, .style = row_style6 });
                }
            },
            .illiquid_row => {
                if (row.lot) |lot| {
                    var illiquid_row_buf: [160]u8 = undefined;
                    const row_text = fmt.fmtIlliquidRow(&illiquid_row_buf, row.symbol, lot.shares, lot.note);
                    const text = try std.fmt.allocPrint(arena, "    {s}", .{row_text});
                    const row_style7 = if (is_cursor) th.selectStyle() else th.mutedStyle();
                    try lines.append(arena, .{ .text = text, .style = row_style7 });
                }
            },
            .drip_summary => {
                var drip_buf: [128]u8 = undefined;
                const drip_text = fmt.fmtDripSummary(&drip_buf, if (row.drip_is_lt) "LT" else "ST", .{
                    .lot_count = row.drip_lot_count,
                    .shares = row.drip_shares,
                    .cost = row.drip_shares * row.drip_avg_cost,
                    .first_date = row.drip_date_first,
                    .last_date = row.drip_date_last,
                });
                const text = try std.fmt.allocPrint(arena, "    {s}", .{drip_text});
                const drip_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
                try lines.append(arena, .{ .text = text, .style = drip_style });
            },
        }
        // Map all styled lines produced by this row back to the row index
        const lines_after = lines.items.len;
        for (lines_before..lines_after) |li| {
            const map_idx = li - state.header_lines;
            if (map_idx < state.line_to_row.len) {
                state.line_to_row[map_idx] = ri;
            }
        }
        state.line_count = lines_after - state.header_lines;
    }

    // Render
    const start = @min(app.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
    try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
}

fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    // Resolve key bindings dynamically so the welcome screen reflects
    // the user's actual keymap (defaults or overridden via keys.srf).
    // Each `keysForGlobal` returns at least one key — global default
    // bindings always exist (verified by the comptime conflict
    // validator + tests).
    const keys: WelcomeKeys = .{
        .symbol_input = (try app.keysForGlobal(arena, .symbol_input))[0],
        .select_next = (try app.keysForGlobal(arena, .select_next))[0],
        .select_prev = (try app.keysForGlobal(arena, .select_prev))[0],
        .prev_tab = (try app.keysForGlobal(arena, .prev_tab))[0],
        .next_tab = (try app.keysForGlobal(arena, .next_tab))[0],
        .help = (try app.keysForGlobal(arena, .help))[0],
        .quit = (try app.keysForGlobal(arena, .quit))[0],
        .tab_1 = (try app.keysForGlobal(arena, .tab_1))[0],
        .tab_5 = (try app.keysForGlobal(arena, .tab_5))[0],
        .expand_collapse = (try app.keysForTabAction(arena, "portfolio", "expand_collapse"))[0],
        .select_symbol = (try app.keysForTabAction(arena, "portfolio", "select_symbol"))[0],
    };
    const lines = try buildWelcomeScreenLines(arena, app.theme, keys);
    try app.drawStyledContent(arena, buf, width, height, lines);
}

/// Pre-resolved key bindings used by `buildWelcomeScreenLines`. All
/// fields are formatted key strings (e.g. `"j"`, `"ctrl+f"`) sourced
/// from the live keymap; the renderer doesn't know about the keymap.
pub const WelcomeKeys = struct {
    symbol_input: []const u8,
    select_next: []const u8,
    select_prev: []const u8,
    prev_tab: []const u8,
    next_tab: []const u8,
    help: []const u8,
    quit: []const u8,
    tab_1: []const u8,
    tab_5: []const u8,
    expand_collapse: []const u8,
    select_symbol: []const u8,
};

/// Build the styled lines for the welcome screen shown when no
/// portfolio is loaded. Pure function over (arena, theme, keys);
/// no App access. Easy to unit-test by passing fixture keys.
pub fn buildWelcomeScreenLines(
    arena: std.mem.Allocator,
    th: theme.Theme,
    keys: WelcomeKeys,
) ![]const StyledLine {
    var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  zfin", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  No portfolio loaded.", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Getting started:", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s:<10}  Enter a stock symbol (e.g. AAPL, VTI)", .{keys.symbol_input}),
        .style = th.contentStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Portfolio mode:", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "    zfin -p portfolio.srf     Load a portfolio file", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "    portfolio.srf             Auto-loaded from cwd if present", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Navigation:", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s} / {s}     Previous / next tab", .{ keys.prev_tab, keys.next_tab }),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s} / {s}     Select next / prev item", .{ keys.select_next, keys.select_prev }),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s:<10}  Expand position lots", .{keys.expand_collapse}),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s:<10}  Select symbol for other tabs", .{keys.select_symbol}),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s}-{s}         Jump to tab", .{ keys.tab_1, keys.tab_5 }),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s:<10}  Full help", .{keys.help}),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "    {s:<10}  Quit", .{keys.quit}),
        .style = th.mutedStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Sample portfolio.srf:", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "    symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() });
    try lines.append(arena, .{ .text = "    symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() });
    return lines.toOwnedSlice(arena);
}

/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
///
/// Goes through the same `portfolio_loader.loadPortfolioFromPaths`
/// the initial load uses, so a manual reload sees the merged view
/// of every `portfolio*.srf` in the resolved directory — same as
/// the CLI.
/// Reload portfolio file from disk. Re-parses files at the
/// captured paths, re-fetches prices (cache-only — no network),
/// and rebuilds the summary + spawns the workers. Distinct
/// from the in-place refresh action (r/F5) which forces a live
/// fetch.
///
/// Goes through the same `loadPortfolioFromPaths` the initial
/// load uses, so a manual reload sees the merged view of every
/// `portfolio*.srf` in the resolved directory — same as the CLI.
///
/// Lifecycle: broadcast onPortfolioReload BEFORE pd.reload so
/// every tab clears derived state that borrows from the
/// portfolio arena. Tabs MUST NOT eager-rebuild from those
/// hooks — pd.reload is about to free the arena out from under
/// them. Instead, after pd.reload completes, app.loadTabData()
/// re-activates the currently-active tab against fresh data.
/// Inactive tabs stay in `loaded = false` and lazy-rebuild on
/// next switch.
pub fn reloadPortfolioFile(state: *State, app: *App) void {
    if (app.portfolio.paths.len == 0) {
        app.setStatus("No portfolio file to reload");
        return;
    }

    // Broadcast onPortfolioReload to every tab so each tab
    // invalidates its own derived state (cached results, view
    // models, cursor / expansion indices that pointed into the
    // about-to-be-freed portfolio data). MUST happen BEFORE
    // pd.reload starts freeing/recreating the underlying data.
    // Tabs MUST NOT eager-rebuild from this hook (see above).
    app.broadcast("onPortfolioReload", .{});

    // Reload watchlist file too (if separate). pd doesn't read
    // watchlist.srf — that's a TUI-side concern.
    tui.freeWatchlist(app.allocator, app.watchlist);
    app.watchlist = null;
    if (app.watchlist_path) |path| {
        app.watchlist = tui.loadWatchlist(app.io, app.allocator, path);
    }

    var watch_syms: std.ArrayList([]const u8) = .empty;
    defer watch_syms.deinit(app.allocator);
    if (app.watchlist) |wl| {
        for (wl) |sym| watch_syms.append(app.allocator, sym) catch |err| std.log.debug("watch_syms append failed: {t}", .{err});
    }

    // pd.reload re-uses captured paths, re-parses, re-fetches
    // prices (.force_refresh = false → honor cache TTLs), and
    // spawns fresh workers.
    _ = app.portfolio.reload(app.today, .{
        .watchlist_syms = watch_syms.items,
    }) catch |err| {
        app.setStatus("Error reloading portfolio file");
        std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err});
        return;
    };
    if (app.portfolio.summary == null) return;

    // Always rebuild portfolio_tab UI state (regardless of
    // which tab is active) — the onPortfolioReload broadcast
    // cleared it above, and if we leave it empty the user
    // could switch back without an activate firing in some
    // paths. Cheap (sort + filter on ~30 holdings).
    sortPortfolioAllocations(state, app);
    buildAccountList(state, app);
    recomputeFilteredPositions(state, app);
    rebuildPortfolioRows(state, app);

    // Re-activate the currently-active tab (whatever it is) so
    // it rebuilds derived state from the freshly-reloaded
    // portfolio data. Tabs that gate on `state.loaded` already
    // got `loaded = false` from the broadcast above, so this
    // call triggers their full re-load. For portfolio_tab
    // itself this is idempotent with the manual rebuild above.
    app.loadTabData();

    app.setStatus("Portfolio reloaded from disk");
}

// ── Account picker ────────────────────────────────────────────

/// Number of header lines in the account picker before the list items start.
/// Used for mouse click hit-testing.
pub const account_picker_header_lines: usize = 3;

/// Draw the account picker overlay (replaces portfolio content).
pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const th = app.theme;
    var lines: std.ArrayList(tui.StyledLine) = .empty;

    const is_searching = state.modal == .account_search;

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Filter by Account", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Build a set of search-highlighted indices for fast lookup
    var search_highlight = std.AutoHashMap(usize, void).init(arena);
    var search_cursor_idx: ?usize = null;
    if (is_searching and state.account_search_matches.items.len > 0) {
        for (state.account_search_matches.items, 0..) |match_idx, si| {
            try search_highlight.put(match_idx, {});
            if (si == state.account_search_cursor) search_cursor_idx = match_idx;
        }
    }

    // Item 0 = "All accounts" (clears filter)
    const total_items = state.account_list.items.len + 1;
    for (0..total_items) |i| {
        const is_selected = if (is_searching)
            (if (search_cursor_idx) |sci| i == sci + 1 else false)
        else
            i == state.account_picker_cursor;
        const marker: []const u8 = if (is_selected) " > " else "   ";

        if (i == 0) {
            const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
            const style = if (is_selected) th.selectStyle() else th.contentStyle();
            const dimmed = is_searching and state.account_search_len > 0;
            try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
        } else {
            const acct_idx = i - 1;
            const label = state.account_list.items[acct_idx];
            const shortcut: u8 = if (acct_idx < state.account_shortcut_keys.items.len) state.account_shortcut_keys.items[acct_idx] else 0;
            const acct_num: ?[]const u8 = if (acct_idx < state.account_numbers.items.len) state.account_numbers.items[acct_idx] else null;

            const text = if (acct_num) |num|
                (if (shortcut != 0)
                    try std.fmt.allocPrint(arena, "{s}{c}: {s} ({s})", .{ marker, shortcut, label, num })
                else
                    try std.fmt.allocPrint(arena, "{s}   {s} ({s})", .{ marker, label, num }))
            else if (shortcut != 0)
                try std.fmt.allocPrint(arena, "{s}{c}: {s}", .{ marker, shortcut, label })
            else
                try std.fmt.allocPrint(arena, "{s}   {s}", .{ marker, label });

            var style = if (is_selected) th.selectStyle() else th.contentStyle();
            if (is_searching and state.account_search_len > 0) {
                if (search_highlight.contains(acct_idx)) {
                    if (!is_selected) style = th.headerStyle();
                } else {
                    style = th.mutedStyle();
                }
            }
            try lines.append(arena, .{ .text = text, .style = style });
        }
    }

    // Search prompt at the bottom
    if (is_searching) {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        const query = state.account_search_buf[0..state.account_search_len];
        const match_count = state.account_search_matches.items.len;
        const prompt = if (query.len > 0)
            try std.fmt.allocPrint(arena, "  /{s}  ({d} match{s})", .{
                query,
                match_count,
                if (match_count != 1) @as([]const u8, "es") else "",
            })
        else
            try std.fmt.allocPrint(arena, "  /", .{});
        try lines.append(arena, .{ .text = prompt, .style = th.headerStyle() });
    } else {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        try lines.append(arena, .{ .text = "  /: search  j/k: navigate  Enter: select  Esc: cancel", .style = th.mutedStyle() });
    }

    // Scroll so cursor is visible
    const effective_cursor = if (is_searching)
        (if (search_cursor_idx) |sci| sci + 1 else 0)
    else
        state.account_picker_cursor;
    const cursor_line = effective_cursor + account_picker_header_lines;
    var start: usize = 0;
    if (cursor_line >= height) {
        start = cursor_line - height + 2;
    }
    start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);

    try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
}

/// Mouse handling for the account-picker modal. Wheel scrolls
/// the cursor; left-click on a list item selects + applies +
/// dismisses. Returns `true` for any consumed event (including
/// non-content clicks) so the picker swallows everything in its
/// mode and the rest of the app's mouse pipeline doesn't see it.
pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
    const total_items = state.account_list.items.len + 1;
    switch (mouse.button) {
        .wheel_up => {
            if (app.shouldDebounceWheel()) return true;
            if (state.account_picker_cursor > 0)
                state.account_picker_cursor -= 1;
            return true;
        },
        .wheel_down => {
            if (app.shouldDebounceWheel()) return true;
            if (total_items > 0 and state.account_picker_cursor < total_items - 1)
                state.account_picker_cursor += 1;
            return true;
        },
        .left => {
            if (mouse.type != .press) return true;
            // Map click row to picker item index. The picker is
            // drawn at content origin (row 1), but the existing
            // hit-test uses raw `mouse.row` against
            // `account_picker_header_lines` — preserve that
            // behavior. (Drift in the picker layout would shift
            // the off-by-one; not changing it here.)
            const content_row: usize = @intCast(mouse.row);
            if (content_row >= account_picker_header_lines) {
                const item_idx = content_row - account_picker_header_lines;
                if (item_idx < total_items) {
                    state.account_picker_cursor = item_idx;
                    applyAccountPickerSelection(state, app);
                    return true;
                }
            }
            return true;
        },
        else => return true,
    }
}

/// Key handler for the account-picker modal. Called from
/// `handleKey` (the framework pre-empt hook) when
/// `state.modal == .account_picker`.
///
/// Modal keys are hardcoded universal conventions (Enter/Esc/q
/// to dismiss, '/' to enter search, 'A' for "All accounts",
/// shortcut keys for instant select). Navigation (j/k/g/G) uses
/// the global keymap so user-rebound nav keys work consistently.
///
/// Returns `true` for any consumed key — including unrecognized
/// keys, which are intentionally swallowed so they can't
/// "leak" through to global keymap matching while the modal is
/// open.
fn handleAccountPickerKey(state: *State, app: *App, key: vaxis.Key) bool {
    const total_items = state.account_list.items.len + 1; // +1 for "All accounts"

    if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
        state.modal = .none;
        return true;
    }

    if (key.codepoint == vaxis.Key.enter) {
        applyAccountPickerSelection(state, app);
        return true;
    }

    // '/' enters search mode
    if (key.matches('/', .{})) {
        state.modal = .account_search;
        state.account_search_len = 0;
        updateAccountSearchMatches(state, app);
        return true;
    }

    // 'A' selects "All accounts" instantly
    if (key.matches('A', .{})) {
        state.account_picker_cursor = 0;
        applyAccountPickerSelection(state, app);
        return true;
    }

    // Check shortcut keys for instant selection
    if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
        const ch: u8 = @intCast(key.codepoint);
        for (state.account_shortcut_keys.items, 0..) |shortcut, i| {
            if (shortcut == ch) {
                state.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
                applyAccountPickerSelection(state, app);
                return true;
            }
        }
    }

    // Navigation via keymap
    if (app.keymap.matchAction(key)) |action| {
        switch (action) {
            .select_next => {
                if (total_items > 0 and state.account_picker_cursor < total_items - 1)
                    state.account_picker_cursor += 1;
            },
            .select_prev => {
                if (state.account_picker_cursor > 0)
                    state.account_picker_cursor -= 1;
            },
            .scroll_top => {
                state.account_picker_cursor = 0;
            },
            .scroll_bottom => {
                if (total_items > 0)
                    state.account_picker_cursor = total_items - 1;
            },
            else => {},
        }
    }
    // Swallow unrecognized keys — modal contract.
    return true;
}

/// Key handler for the account-search modal (`/` within picker).
/// Called from `handleKey` when `state.modal == .account_search`.
/// Modal keys are hardcoded.
///
/// Returns `true` for any consumed key (always — modal swallows
/// everything).
fn handleAccountSearchKey(state: *State, app: *App, key: vaxis.Key) bool {
    // Escape: cancel search, return to picker
    if (key.codepoint == vaxis.Key.escape) {
        state.modal = .account_picker;
        state.account_search_len = 0;
        return true;
    }

    // Enter: select the first match (or current search cursor)
    if (key.codepoint == vaxis.Key.enter) {
        if (state.account_search_matches.items.len > 0) {
            const match_idx = state.account_search_matches.items[state.account_search_cursor];
            state.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
        }
        state.account_search_len = 0;
        applyAccountPickerSelection(state, app);
        return true;
    }

    // Ctrl+N / Ctrl+P or arrow keys to cycle through matches
    if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
        if (state.account_search_matches.items.len > 0 and
            state.account_search_cursor < state.account_search_matches.items.len - 1)
            state.account_search_cursor += 1;
        return true;
    }
    if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
        if (state.account_search_cursor > 0)
            state.account_search_cursor -= 1;
        return true;
    }

    // Backspace
    if (key.codepoint == vaxis.Key.backspace) {
        if (state.account_search_len > 0) {
            state.account_search_len -= 1;
            updateAccountSearchMatches(state, app);
        }
        return true;
    }

    // Ctrl+U: clear search
    if (key.matches('u', .{ .ctrl = true })) {
        state.account_search_len = 0;
        updateAccountSearchMatches(state, app);
        return true;
    }

    // Printable ASCII
    if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and state.account_search_len < state.account_search_buf.len) {
        state.account_search_buf[state.account_search_len] = @intCast(key.codepoint);
        state.account_search_len += 1;
        updateAccountSearchMatches(state, app);
        return true;
    }
    // Swallow unrecognized keys — modal contract.
    return true;
}

/// Update search match indices based on current search string.
/// Searches account name AND account number (so users can find
/// "401k" by typing the number from accounts.srf).
fn updateAccountSearchMatches(state: *State, app: *App) void {
    state.account_search_matches.clearRetainingCapacity();
    const query = state.account_search_buf[0..state.account_search_len];
    if (query.len == 0) return;

    var lower_query: [64]u8 = undefined;
    for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
    const lq = lower_query[0..query.len];

    for (state.account_list.items, 0..) |acct, i| {
        if (containsLower(acct, lq)) {
            state.account_search_matches.append(app.allocator, i) catch continue;
        } else if (i < state.account_numbers.items.len) {
            if (state.account_numbers.items[i]) |num| {
                if (containsLower(num, lq)) {
                    state.account_search_matches.append(app.allocator, i) catch continue;
                }
            }
        }
    }

    if (state.account_search_cursor >= state.account_search_matches.items.len) {
        state.account_search_cursor = if (state.account_search_matches.items.len > 0)
            state.account_search_matches.items.len - 1
        else
            0;
    }
}

/// Case-insensitive substring search. Linear scan; haystacks here
/// are short (account names) so a smarter algorithm wouldn't pay
/// off.
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
    if (needle_lower.len == 0) return true;
    if (haystack.len < needle_lower.len) return false;
    const end = haystack.len - needle_lower.len + 1;
    for (0..end) |start| {
        var matched = true;
        for (0..needle_lower.len) |j| {
            if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
                matched = false;
                break;
            }
        }
        if (matched) return true;
    }
    return false;
}

/// Apply the current account picker selection and return to
/// normal mode. Selection 0 = "All accounts" (clears filter);
/// 1..N = nth account in `account_list`.
fn applyAccountPickerSelection(state: *State, app: *App) void {
    if (state.account_picker_cursor == 0) {
        // "All accounts" — clear filter
        setAccountFilter(state, app, null);
    } else {
        const idx = state.account_picker_cursor - 1;
        if (idx < state.account_list.items.len) {
            setAccountFilter(state, app, state.account_list.items[idx]);
        }
    }
    state.modal = .none;
    state.cursor = 0;
    app.scroll_offset = 0;
    rebuildPortfolioRows(state, app);

    if (state.account_filter) |af| {
        var tmp_buf: [256]u8 = undefined;
        const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
        app.setStatus(msg);
    } else {
        app.setStatus("Filter cleared: showing all accounts");
    }
}

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

const testing = std.testing;

test "PortfolioSortField next/prev" {
    // next from first field
    try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?);
    // next from last field returns null
    try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next());
    // prev from first returns null
    try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev());
    // prev from last
    try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?);
}

test "PortfolioSortField label" {
    try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label());
    try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label());
}

test "SortDirection flip and indicator" {
    try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip());
    try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip());
    try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲
    try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼
}

test "buildWelcomeScreenLines: includes resolved keys in expected slots" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const keys: WelcomeKeys = .{
        .symbol_input = "/",
        .select_next = "j",
        .select_prev = "k",
        .prev_tab = "h",
        .next_tab = "l",
        .help = "?",
        .quit = "q",
        .tab_1 = "1",
        .tab_5 = "5",
        .expand_collapse = "enter",
        .select_symbol = "s",
    };

    const lines = try buildWelcomeScreenLines(arena, theme.default_theme, keys);

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

    // Header + body sections present.
    try testing.expect(std.mem.indexOf(u8, text, "zfin") != null);
    try testing.expect(std.mem.indexOf(u8, text, "No portfolio loaded.") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Getting started:") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Portfolio mode:") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Navigation:") != null);
    try testing.expect(std.mem.indexOf(u8, text, "Sample portfolio.srf:") != null);

    // All resolved keys appear in their respective rows. Format
    // strings use `{s:<10}` (ten-char field) and `  ` (two-space)
    // gap before the description, so the gap between key and
    // description = (10 - keylen) padding + 2 separator.
    try testing.expect(std.mem.indexOf(u8, text, "/           Enter a stock symbol") != null);
    try testing.expect(std.mem.indexOf(u8, text, "h / l     Previous / next tab") != null);
    try testing.expect(std.mem.indexOf(u8, text, "j / k     Select next / prev item") != null);
    try testing.expect(std.mem.indexOf(u8, text, "enter       Expand position lots") != null);
    try testing.expect(std.mem.indexOf(u8, text, "s           Select symbol for other tabs") != null);
    try testing.expect(std.mem.indexOf(u8, text, "1-5         Jump to tab") != null);
    try testing.expect(std.mem.indexOf(u8, text, "?           Full help") != null);
    try testing.expect(std.mem.indexOf(u8, text, "q           Quit") != null);
}

test "buildWelcomeScreenLines: respects rebound keys" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // User-rebound keys: arbitrary substitutions.
    const keys: WelcomeKeys = .{
        .symbol_input = "ctrl+s",
        .select_next = "down",
        .select_prev = "up",
        .prev_tab = "shift+tab",
        .next_tab = "tab",
        .help = "F1",
        .quit = "ctrl+q",
        .tab_1 = "f1",
        .tab_5 = "f5",
        .expand_collapse = "space",
        .select_symbol = "x",
    };

    const lines = try buildWelcomeScreenLines(arena, theme.default_theme, keys);

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

    // Verify every rebound key is rendered.
    try testing.expect(std.mem.indexOf(u8, text, "ctrl+s") != null);
    try testing.expect(std.mem.indexOf(u8, text, "shift+tab / tab") != null);
    try testing.expect(std.mem.indexOf(u8, text, "down / up") != null);
    try testing.expect(std.mem.indexOf(u8, text, "space") != null);
    try testing.expect(std.mem.indexOf(u8, text, "x           Select symbol") != null);
    try testing.expect(std.mem.indexOf(u8, text, "f1-f5") != null);
    try testing.expect(std.mem.indexOf(u8, text, "F1          Full help") != null);
    try testing.expect(std.mem.indexOf(u8, text, "ctrl+q      Quit") != null);

    // No default keys leaked through (sanity).
    try testing.expect(std.mem.indexOf(u8, text, "  /         Enter a stock symbol") == null);
    try testing.expect(std.mem.indexOf(u8, text, "h / l") == null);
    try testing.expect(std.mem.indexOf(u8, text, "j / k") == null);
}

test "matchesAccountFilter: no filter = pass-through" {
    const state: State = .{};
    try testing.expect(matchesAccountFilter(&state, "Brokerage"));
    try testing.expect(matchesAccountFilter(&state, null));
}

test "matchesAccountFilter: with filter, only matching account passes" {
    const state: State = .{ .account_filter = "Brokerage" };
    try testing.expect(matchesAccountFilter(&state, "Brokerage"));
    try testing.expect(!matchesAccountFilter(&state, "IRA"));
}

test "matchesAccountFilter: with filter, null account fails" {
    const state: State = .{ .account_filter = "Brokerage" };
    try testing.expect(!matchesAccountFilter(&state, null));
}

test "ensureCursorVisible: cursor above viewport scrolls up" {
    var state: State = .{ .cursor = 5, .header_lines = 2 };
    var scroll: usize = 20;
    ensureCursorVisible(&state, &scroll, 10);
    // cursor_row = 5 + 2 = 7, which is < 20, so scroll_offset = 7.
    try testing.expectEqual(@as(usize, 7), scroll);
}

test "ensureCursorVisible: cursor below viewport scrolls down" {
    var state: State = .{ .cursor = 50, .header_lines = 2 };
    var scroll: usize = 0;
    ensureCursorVisible(&state, &scroll, 10);
    // cursor_row = 52, scroll_offset = 0, vis = 10, 52 >= 0+10
    // → scroll = 52 - 10 + 1 = 43.
    try testing.expectEqual(@as(usize, 43), scroll);
}

test "ensureCursorVisible: cursor inside viewport leaves scroll alone" {
    var state: State = .{ .cursor = 5, .header_lines = 2 };
    var scroll: usize = 0;
    ensureCursorVisible(&state, &scroll, 20);
    // cursor_row = 7, in [0, 20), no change.
    try testing.expectEqual(@as(usize, 0), scroll);
}

test "ensureCursorVisible: zero visible height is a no-op for the lower bound" {
    var state: State = .{ .cursor = 0, .header_lines = 0 };
    var scroll: usize = 5;
    ensureCursorVisible(&state, &scroll, 0);
    // cursor_row = 0 < 5 → scroll = 0. Lower-bound branch fires.
    try testing.expectEqual(@as(usize, 0), scroll);
}
