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

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

/// One row in the options tab's flattened display list. Mix of
/// expiration-group headers, calls/puts section headers, and
/// individual contract rows; rebuilt by `rebuildRows` whenever
/// expansion state changes.
pub const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put };

pub const OptionsRow = struct {
    kind: OptionsRowKind,
    exp_idx: usize = 0, // index into options_data chains
    contract: ?zfin.OptionContract = null,
};

// ── Tab-local action enum ─────────────────────────────────────
//
// Options tab keybinds:
//   - Enter             : expand/collapse the row at the cursor.
//   - `c` / `p`         : collapse-or-expand all calls / puts.
//   - Ctrl-1 .. Ctrl-9  : set NTM filter to N strikes around ATM.

pub const Action = enum {
    /// Toggle the row at the cursor: expand/collapse an expiration,
    /// or collapse/expand the calls/puts subsection at the cursor.
    /// Mouse single-click on a row dispatches the same action.
    expand_collapse,
    collapse_all_calls,
    collapse_all_puts,
    filter_1,
    filter_2,
    filter_3,
    filter_4,
    filter_5,
    filter_6,
    filter_7,
    filter_8,
    filter_9,
};

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

pub const State = struct {
    /// Loaded options chains for the active symbol. Owned by State;
    /// freed via `deinit` and `reload`.
    chains: ?[]zfin.OptionsChain = null,
    /// Whether `activate` has populated `chains` (or set `disabled`).
    /// The chains slice is null until the first successful fetch
    /// even if `loaded == true` (failed fetches still mark loaded).
    loaded: bool = false,
    /// Timestamp of the chains fetch — drives the "data Xs ago"
    /// header readout.
    timestamp: i64 = 0,
    /// Cursor position in the flattened options rows view.
    cursor: usize = 0,
    /// Per-expiration: is the expiration expanded (showing calls
    /// + puts subsections)?
    expanded: [64]bool = @splat(false),
    /// Per-expiration: when expanded, are the calls collapsed?
    calls_collapsed: [64]bool = @splat(false),
    /// Per-expiration: when expanded, are the puts collapsed?
    puts_collapsed: [64]bool = @splat(false),
    /// Number of strikes around ATM to show. Adjusted with Ctrl-1..9.
    near_the_money: usize = 8,
    /// Flattened display rows (expirations + headers + contracts).
    /// Rebuilt by `rebuildRows` whenever `expanded` or
    /// `near_the_money` changes.
    rows: std.ArrayList(OptionsRow) = .empty,
    /// Number of styled lines emitted before the first data row.
    /// Used by mouse-click handling to map screen rows to data rows.
    header_lines: usize = 0,
};

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

pub const meta: framework.TabMeta(Action) = .{
    .label = "Options",
    .default_bindings = &.{
        .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
        .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
        .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },
        .{ .action = .filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } },
        .{ .action = .filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } },
    },
    .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
        .expand_collapse = "Expand/collapse row",
        .collapse_all_calls = "Toggle all calls",
        .collapse_all_puts = "Toggle all puts",
        .filter_1 = "Filter +/- 1 NTM",
        .filter_2 = "Filter +/- 2 NTM",
        .filter_3 = "Filter +/- 3 NTM",
        .filter_4 = "Filter +/- 4 NTM",
        .filter_5 = "Filter +/- 5 NTM",
        .filter_6 = "Filter +/- 6 NTM",
        .filter_7 = "Filter +/- 7 NTM",
        .filter_8 = "Filter +/- 8 NTM",
        .filter_9 = "Filter +/- 9 NTM",
    }),
    .status_hints = &.{
        .collapse_all_calls,
        .collapse_all_puts,
    },
};

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 {
        if (state.chains) |chains| {
            zfin.OptionsChain.freeSlice(app.allocator, chains);
        }
        state.rows.deinit(app.allocator);
        state.* = .{};
    }

    pub fn activate(state: *State, app: *App) !void {
        if (app.symbol.len == 0) return;
        if (state.loaded) return;
        loadData(state, app);
    }

    pub const deactivate = framework.noopDeactivate(State);

    pub fn reload(state: *State, app: *App) !void {
        if (app.symbol.len > 0) {
            app.svc.invalidate(app.symbol, .options);
        }
        // Drop chains first so loadData starts clean.
        if (state.chains) |chains| {
            zfin.OptionsChain.freeSlice(app.allocator, chains);
        }
        // Preserve user UX choices across refresh: cursor, expanded
        // sections, near-the-money filter. Just clear the data.
        state.chains = null;
        state.loaded = false;
        state.timestamp = 0;
        loadData(state, app);
    }

    pub const tick = framework.noopTick(State);

    pub fn handleAction(state: *State, app: *App, action: Action) void {
        switch (action) {
            .collapse_all_calls => toggleAllCallsPuts(state, app, true),
            .collapse_all_puts => toggleAllCallsPuts(state, app, false),
            .expand_collapse => toggleExpandAtCursor(state, app.allocator),
            .filter_1, .filter_2, .filter_3, .filter_4, .filter_5, .filter_6, .filter_7, .filter_8, .filter_9 => {
                const n = @intFromEnum(action) - @intFromEnum(Action.filter_1) + 1;
                state.near_the_money = n;
                rebuildRows(state, app.allocator);
                var status_buf: [32]u8 = undefined;
                const msg = std.fmt.bufPrint(&status_buf, "+/- {d} NTM strikes", .{n}) catch "Filter changed";
                app.setStatus(msg);
            },
        }
    }

    /// Mouse handling: a single-click on a data row moves the
    /// cursor to that row and toggles expand/collapse — same effect
    /// as pressing Enter on the row. Returns `true` if the click
    /// landed on a data row (consumed); `false` otherwise (unhandled,
    /// e.g. clicks above the table or on blank lines).
    pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
        if (mouse.button != .left) return false;
        if (mouse.type != .press) return false;
        const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
        if (content_row < state.header_lines) return false;
        if (state.rows.items.len == 0) return false;

        // Walk rows tracking styled line position to find which row
        // was clicked. Each row = 1 styled line, except `puts_header`
        // which emits an extra blank line before it.
        const target_line = content_row - state.header_lines;
        var current_line: usize = 0;
        for (state.rows.items, 0..) |orow, oi| {
            if (orow.kind == .puts_header) current_line += 1; // extra blank
            if (current_line == target_line) {
                state.cursor = oi;
                toggleExpandAtCursor(state, app.allocator);
                return true;
            }
            current_line += 1;
        }
        return false;
    }

    pub fn isDisabled(app: *App) bool {
        _ = app;
        return false;
    }

    /// Symbol-change reset. Drops chains + display rows, clears
    /// cursor + per-expiration collapse flags. Preserves
    /// `near_the_money` because that's a persistent UX choice
    /// (not symbol-bound).
    pub fn onSymbolChange(state: *State, app: *App) void {
        if (state.chains) |chains| {
            zfin.OptionsChain.freeSlice(app.allocator, chains);
        }
        state.chains = null;
        state.loaded = false;
        state.timestamp = 0;
        state.cursor = 0;
        state.expanded = @splat(false);
        state.calls_collapsed = @splat(false);
        state.puts_collapsed = @splat(false);
        state.rows.clearRetainingCapacity();
        state.header_lines = 0;
        // near_the_money preserved.
    }

    /// Sync the row cursor to the new scroll extreme. The framework
    /// updates `app.scroll_offset` itself; this hook just keeps the
    /// tab's own cursor consistent with what's now visible.
    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 row cursor by one row in `delta`'s direction. The
    /// magnitude of `delta` is ignored — keys and wheel events
    /// both move by a single row (matching legacy behavior). Returns
    /// `false` when there are no rows to navigate so the framework
    /// falls through to scrolling the viewport instead.
    pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
        if (state.rows.items.len == 0) return false;
        stepCursor(&state.cursor, state.rows.items.len, delta);
        ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
        return true;
    }

    /// Mouse wheel: always scroll the viewport, never move the
    /// cursor. Keeps wheel-as-look-around and cursor-as-pointer
    /// distinct. The framework falls through to viewport scroll
    /// when this returns false.
    pub fn onWheelMove(state: *State, app: *App, delta: isize) bool {
        _ = state;
        _ = app;
        _ = delta;
        return false;
    }
};

// ── Cursor movement / visibility (private; called from onCursorMove) ──

fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void {
    if (direction > 0) {
        if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1;
    } else {
        if (cursor.* > 0) cursor.* -= 1;
    }
}

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;
    }
    if (cursor_row >= scroll_offset.* + visible_height) {
        scroll_offset.* = cursor_row - visible_height + 1;
    }
}

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

fn loadData(state: *State, app: *App) void {
    state.loaded = true;
    if (state.chains) |chains| {
        zfin.OptionsChain.freeSlice(app.allocator, chains);
    }
    state.chains = null;

    const result = app.svc.getOptions(app.symbol, .{}) catch |err| {
        switch (err) {
            zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
            else => app.setStatus("Error loading options"),
        }
        return;
    };
    state.chains = result.data;
    state.timestamp = result.timestamp;
    state.cursor = 0;
    state.expanded = @splat(false);
    state.calls_collapsed = @splat(false);
    state.puts_collapsed = @splat(false);
    rebuildRows(state, app.allocator);
    app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
}

// ── Expand/collapse the row at the cursor ─────────────────────
//
// Called both from the keybind handler (`expand_collapse` action,
// bound to Enter) and from the mouse handler (single-click on a
// row). The behavior depends on the row kind:
//   - `expiration` row: toggle the expiration's expanded flag
//     (showing/hiding the calls + puts subsections).
//   - `calls_header`/`puts_header`: toggle that subsection's
//     collapsed flag.
//   - call/put contract rows: no-op (clicking a contract is
//     reserved for future per-contract actions).
//
// After any change, rebuilds the flat row list to reflect the new
// layout. No-op if the cursor is out of range or rows are empty.

fn toggleExpandAtCursor(state: *State, allocator: std.mem.Allocator) 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) {
        .expiration => {
            if (row.exp_idx < state.expanded.len) {
                state.expanded[row.exp_idx] = !state.expanded[row.exp_idx];
                rebuildRows(state, allocator);
            }
        },
        .calls_header => {
            if (row.exp_idx < state.calls_collapsed.len) {
                state.calls_collapsed[row.exp_idx] = !state.calls_collapsed[row.exp_idx];
                rebuildRows(state, allocator);
            }
        },
        .puts_header => {
            if (row.exp_idx < state.puts_collapsed.len) {
                state.puts_collapsed[row.exp_idx] = !state.puts_collapsed[row.exp_idx];
                rebuildRows(state, allocator);
            }
        },
        // Clicking on a contract does nothing (yet).
        else => {},
    }
}

// ── Row rebuilding (after expansion/collapse changes) ────────

pub fn rebuildRows(state: *State, allocator: std.mem.Allocator) void {
    state.rows.clearRetainingCapacity();
    const chains = state.chains orelse return;
    const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0);

    for (chains, 0..) |chain, ci| {
        state.rows.append(allocator, .{
            .kind = .expiration,
            .exp_idx = ci,
        }) catch continue;

        if (ci < state.expanded.len and state.expanded[ci]) {
            // Calls header (always shown when expanded, acts as toggle)
            state.rows.append(allocator, .{
                .kind = .calls_header,
                .exp_idx = ci,
            }) catch continue;

            // Calls contracts (only if not collapsed)
            if (!(ci < state.calls_collapsed.len and state.calls_collapsed[ci])) {
                const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, state.near_the_money);
                for (filtered_calls) |cc| {
                    state.rows.append(allocator, .{
                        .kind = .call,
                        .exp_idx = ci,
                        .contract = cc,
                    }) catch continue;
                }
            }

            // Puts header
            state.rows.append(allocator, .{
                .kind = .puts_header,
                .exp_idx = ci,
            }) catch continue;

            // Puts contracts (only if not collapsed)
            if (!(ci < state.puts_collapsed.len and state.puts_collapsed[ci])) {
                const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, state.near_the_money);
                for (filtered_puts) |p| {
                    state.rows.append(allocator, .{
                        .kind = .put,
                        .exp_idx = ci,
                        .contract = p,
                    }) catch continue;
                }
            }
        }
    }
}

// ── All-calls / all-puts toggle ──────────────────────────────

fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void {
    const chains = state.chains orelse return;
    // Determine whether to collapse or expand: if any expanded chain
    // has this section visible, collapse all; otherwise expand all.
    var any_visible = false;
    for (chains, 0..) |_, ci| {
        if (ci >= state.expanded.len) break;
        if (!state.expanded[ci]) continue; // only count expanded expirations
        if (is_calls) {
            if (ci < state.calls_collapsed.len and !state.calls_collapsed[ci]) {
                any_visible = true;
                break;
            }
        } else {
            if (ci < state.puts_collapsed.len and !state.puts_collapsed[ci]) {
                any_visible = true;
                break;
            }
        }
    }
    const new_state = any_visible;
    for (chains, 0..) |_, ci| {
        if (ci >= 64) break;
        if (is_calls) {
            state.calls_collapsed[ci] = new_state;
        } else {
            state.puts_collapsed[ci] = new_state;
        }
    }
    rebuildRows(state, app.allocator);
    if (is_calls) {
        app.setStatus(if (new_state) "All calls collapsed" else "All calls expanded");
    } else {
        app.setStatus(if (new_state) "All puts collapsed" else "All puts expanded");
    }
}

/// Format the underlying-price header line shown above the option
/// chains. Pure function over the price, expiration count, current
/// NTM filter, and the resolved filter_1 / filter_9 key strings;
/// allocates in `arena`.
pub fn formatUnderlyingHeader(
    arena: std.mem.Allocator,
    price: f64,
    expiration_count: usize,
    near_the_money: usize,
    filter_1_key: []const u8,
    filter_9_key: []const u8,
) ![]const u8 {
    return std.fmt.allocPrint(
        arena,
        "  Underlying: {f}    {d} expiration(s)    +/- {d} strikes NTM ({s}..{s} to change)",
        .{ Money.from(price), expiration_count, near_the_money, filter_1_key, filter_9_key },
    );
}

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

pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    const th = app.theme;
    var lines: std.ArrayList(StyledLine) = .empty;

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

    if (app.symbol.len == 0) {
        try lines.append(arena, .{ .text = "  No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    }

    const chains = state.chains orelse {
        try lines.append(arena, .{ .text = "  Loading options data...", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    };

    if (chains.len == 0) {
        try lines.append(arena, .{ .text = "  No options data found.", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    }

    var opt_ago_buf: [16]u8 = undefined;
    // wall-clock required: per-frame "now" for the "refreshed Xs ago"
    // readout. Captured here rather than on `app` so it refreshes every
    // time this tab renders.
    const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
    const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, state.timestamp, now_s);
    if (opt_ago.len > 0) {
        try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  Options: {s}  (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() });
    } else {
        try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "  Options: {s}", .{app.symbol}), .style = th.headerStyle() });
    }

    if (chains[0].underlying_price) |price| {
        // Resolve filter_1 and filter_9 keys for the hint, so a
        // user-rebound keymap shows the actual range.
        const f1_keys = try app.keysForTabAction(arena, "options", "filter_1");
        const f9_keys = try app.keysForTabAction(arena, "options", "filter_9");
        const text = try formatUnderlyingHeader(arena, price, chains.len, state.near_the_money, f1_keys[0], f9_keys[0]);
        try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
    }

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

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

    // Flat list of options rows with inline expand/collapse
    for (state.rows.items, 0..) |row, ri| {
        const is_cursor = ri == state.cursor;
        switch (row.kind) {
            .expiration => {
                if (row.exp_idx < chains.len) {
                    const chain = chains[row.exp_idx];
                    const is_expanded = row.exp_idx < state.expanded.len and state.expanded[row.exp_idx];
                    const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
                    const arrow: []const u8 = if (is_expanded) "v " else "> ";
                    const text = try std.fmt.allocPrint(arena, "  {s}{f}  ({d} calls, {d} puts)", .{
                        arrow,
                        chain.expiration,
                        chain.calls.len,
                        chain.puts.len,
                    });
                    const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle();
                    try lines.append(arena, .{ .text = text, .style = style });
                }
            },
            .calls_header => {
                const calls_collapsed = row.exp_idx < state.calls_collapsed.len and state.calls_collapsed[row.exp_idx];
                const arrow: []const u8 = if (calls_collapsed) "    > " else "    v ";
                const style = if (is_cursor) th.selectStyle() else th.headerStyle();
                try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}  Calls", .{
                    arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
                }), .style = style });
            },
            .puts_header => {
                const puts_collapsed = row.exp_idx < state.puts_collapsed.len and state.puts_collapsed[row.exp_idx];
                const arrow: []const u8 = if (puts_collapsed) "    > " else "    v ";
                try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
                const style = if (is_cursor) th.selectStyle() else th.headerStyle();
                try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}  Puts", .{
                    arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
                }), .style = style });
            },
            .call => {
                if (row.contract) |cc| {
                    const atm_price = chains[0].underlying_price orelse 0;
                    const itm = cc.strike <= atm_price;
                    const prefix: []const u8 = if (itm) "      |" else "       ";
                    var contract_buf: [128]u8 = undefined;
                    const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
                    const style = if (is_cursor) th.selectStyle() else th.contentStyle();
                    try lines.append(arena, .{ .text = text, .style = style });
                }
            },
            .put => {
                if (row.contract) |p| {
                    const atm_price = chains[0].underlying_price orelse 0;
                    const itm = p.strike >= atm_price;
                    const prefix: []const u8 = if (itm) "      |" else "       ";
                    var contract_buf: [128]u8 = undefined;
                    const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
                    const style = if (is_cursor) th.selectStyle() else th.contentStyle();
                    try lines.append(arena, .{ .text = text, .style = style });
                }
            },
        }
    }

    return lines.toOwnedSlice(arena);
}

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

const testing = std.testing;

test "formatUnderlyingHeader: includes price, expirations, NTM, and key range" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatUnderlyingHeader(arena, 175.42, 7, 5, "ctrl+1", "ctrl+9");
    try testing.expect(std.mem.indexOf(u8, text, "$175.42") != null);
    try testing.expect(std.mem.indexOf(u8, text, "7 expiration(s)") != null);
    try testing.expect(std.mem.indexOf(u8, text, "+/- 5 strikes NTM") != null);
    try testing.expect(std.mem.indexOf(u8, text, "(ctrl+1..ctrl+9 to change)") != null);
}

test "formatUnderlyingHeader: respects rebound keys" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const text = try formatUnderlyingHeader(arena, 100.0, 3, 1, "F1", "F9");
    try testing.expect(std.mem.indexOf(u8, text, "(F1..F9 to change)") != null);
}

fn makeContract(strike: f64, kind: @import("../models/option.zig").ContractType) zfin.OptionContract {
    return .{
        .contract_type = kind,
        .strike = strike,
        .expiration = zfin.Date.fromYmd(2024, 12, 20),
    };
}

test "rebuildRows: empty chains produces empty rows" {
    var state: State = .{};
    rebuildRows(&state, testing.allocator);
    defer state.rows.deinit(testing.allocator);
    try testing.expectEqual(@as(usize, 0), state.rows.items.len);
}

test "rebuildRows: collapsed expirations emit only the expiration rows" {
    const calls = [_]zfin.OptionContract{
        makeContract(100, .call),
        makeContract(110, .call),
    };
    const puts = [_]zfin.OptionContract{makeContract(95, .put)};
    var chain_arr = [_]zfin.OptionsChain{
        .{
            .underlying_symbol = "AAPL",
            .underlying_price = 105,
            .expiration = zfin.Date.fromYmd(2024, 12, 20),
            .calls = &calls,
            .puts = &puts,
        },
        .{
            .underlying_symbol = "AAPL",
            .underlying_price = 105,
            .expiration = zfin.Date.fromYmd(2025, 1, 17),
            .calls = &calls,
            .puts = &puts,
        },
    };
    var state: State = .{ .chains = &chain_arr };
    rebuildRows(&state, testing.allocator);
    defer state.rows.deinit(testing.allocator);
    // Two collapsed expirations = two rows.
    try testing.expectEqual(@as(usize, 2), state.rows.items.len);
    try testing.expectEqual(OptionsRowKind.expiration, state.rows.items[0].kind);
    try testing.expectEqual(OptionsRowKind.expiration, state.rows.items[1].kind);
}

test "rebuildRows: expanded expiration emits headers + filtered contracts" {
    // 5 calls / 5 puts spread around strike 100 (ATM at 100).
    const calls = [_]zfin.OptionContract{
        makeContract(90, .call),
        makeContract(95, .call),
        makeContract(100, .call),
        makeContract(105, .call),
        makeContract(110, .call),
    };
    const puts = [_]zfin.OptionContract{
        makeContract(90, .put),
        makeContract(95, .put),
        makeContract(100, .put),
        makeContract(105, .put),
        makeContract(110, .put),
    };
    var chain_arr = [_]zfin.OptionsChain{.{
        .underlying_symbol = "AAPL",
        .underlying_price = 100,
        .expiration = zfin.Date.fromYmd(2024, 12, 20),
        .calls = &calls,
        .puts = &puts,
    }};
    var state: State = .{
        .chains = &chain_arr,
        .near_the_money = 2, // +/- 2 strikes from ATM
    };
    state.expanded[0] = true; // expand first expiration

    rebuildRows(&state, testing.allocator);
    defer state.rows.deinit(testing.allocator);

    // Expected rows: expiration + calls_header + N calls + puts_header + N puts.
    // filterNearMoney with near_the_money=2 picks ATM ± 2 strikes;
    // exactly how many varies by impl, but headers are always present.
    var found_calls_header = false;
    var found_puts_header = false;
    for (state.rows.items) |r| {
        if (r.kind == .calls_header) found_calls_header = true;
        if (r.kind == .puts_header) found_puts_header = true;
    }
    try testing.expect(found_calls_header);
    try testing.expect(found_puts_header);
    try testing.expect(state.rows.items.len > 3); // at least exp + 2 headers + 1 contract
}

test "rebuildRows: calls_collapsed hides call contracts but keeps puts" {
    const calls = [_]zfin.OptionContract{
        makeContract(100, .call),
    };
    const puts = [_]zfin.OptionContract{
        makeContract(100, .put),
    };
    var chain_arr = [_]zfin.OptionsChain{.{
        .underlying_symbol = "AAPL",
        .underlying_price = 100,
        .expiration = zfin.Date.fromYmd(2024, 12, 20),
        .calls = &calls,
        .puts = &puts,
    }};
    var state: State = .{
        .chains = &chain_arr,
        .near_the_money = 5,
    };
    state.expanded[0] = true;
    state.calls_collapsed[0] = true; // hide calls

    rebuildRows(&state, testing.allocator);
    defer state.rows.deinit(testing.allocator);

    var call_count: usize = 0;
    var put_count: usize = 0;
    for (state.rows.items) |r| {
        if (r.kind == .call) call_count += 1;
        if (r.kind == .put) put_count += 1;
    }
    try testing.expectEqual(@as(usize, 0), call_count);
    try testing.expect(put_count > 0);
}

test "toggleExpandAtCursor: cursor on expiration row toggles expanded flag" {
    const calls = [_]zfin.OptionContract{makeContract(100, .call)};
    const puts = [_]zfin.OptionContract{makeContract(100, .put)};
    var chain_arr = [_]zfin.OptionsChain{.{
        .underlying_symbol = "AAPL",
        .underlying_price = 100,
        .expiration = zfin.Date.fromYmd(2024, 12, 20),
        .calls = &calls,
        .puts = &puts,
    }};
    var state: State = .{
        .chains = &chain_arr,
        .near_the_money = 5,
    };
    rebuildRows(&state, testing.allocator);
    defer state.rows.deinit(testing.allocator);

    // Cursor starts on the expiration row (only row in collapsed state).
    try testing.expect(!state.expanded[0]);
    toggleExpandAtCursor(&state, testing.allocator);
    try testing.expect(state.expanded[0]);
    toggleExpandAtCursor(&state, testing.allocator); // cursor now on... still row 0 = expiration row
    try testing.expect(!state.expanded[0]);
}

test "toggleExpandAtCursor: empty rows is a no-op" {
    var state: State = .{};
    toggleExpandAtCursor(&state, testing.allocator);
    // No crash, no change.
    try testing.expectEqual(@as(usize, 0), state.rows.items.len);
}

test "stepCursor: positive direction advances within bounds" {
    var cursor: usize = 0;
    stepCursor(&cursor, 5, 1);
    try testing.expectEqual(@as(usize, 1), cursor);
    stepCursor(&cursor, 5, 3);
    try testing.expectEqual(@as(usize, 2), cursor);
}

test "stepCursor: positive direction clamped at row_count - 1" {
    var cursor: usize = 4;
    stepCursor(&cursor, 5, 1);
    try testing.expectEqual(@as(usize, 4), cursor);
}

test "stepCursor: negative direction decrements" {
    var cursor: usize = 3;
    stepCursor(&cursor, 5, -1);
    try testing.expectEqual(@as(usize, 2), cursor);
}

test "stepCursor: negative direction clamped at 0" {
    var cursor: usize = 0;
    stepCursor(&cursor, 5, -1);
    try testing.expectEqual(@as(usize, 0), cursor);
}

test "stepCursor: zero rows is a no-op" {
    var cursor: usize = 0;
    stepCursor(&cursor, 0, 1);
    try testing.expectEqual(@as(usize, 0), cursor);
}

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, vis = 10, 52 >= 0+10 → scroll = 52 - 10 + 1 = 43.
    try testing.expectEqual(@as(usize, 43), scroll);
}

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

test "ensureCursorVisible: cursor inside viewport leaves scroll alone" {
    var state: State = .{ .cursor = 3, .header_lines = 2 };
    var scroll: usize = 0;
    ensureCursorVisible(&state, &scroll, 20);
    try testing.expectEqual(@as(usize, 0), scroll);
}
