//! TUI history tab — portfolio value timeline + compare-mode overlay.
//!
//! Timeline layout (top-to-bottom):
//!   1. Rolling-windows block for the focused metric
//!      (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time)
//!   2. Braille timeline chart for the focused metric
//!   3. "Recent snapshots" table: Liquid | Illiquid | Net Worth with
//!      per-row Δ vs. previous row. Newest-first. Row colored by the
//!      focused-metric delta.
//!
//! Compare mode overrides the entire output with a `CompareView`
//! render when two rows have been selected (via `s` / space) and
//! confirmed via `c`. Esc or another `c` returns to timeline.
//!
//! Selection UX:
//!   - `s` / space — toggle selection of the row under the cursor.
//!     Up to two rows can be selected; a third attempt is rejected
//!     with a status hint.
//!   - `c` — run compare if exactly two rows are selected; otherwise
//!     status hint.
//!   - Esc — exit compare view if active, else clear pending selections.
//!
//! The "today (live)" pseudo-row is conditional: it appears as the
//! newest row when `app.portfolio.summary` and `app.prefetched_prices`
//! are populated. When present, `history_cursor = 0` points at it.
//!
//! Consumes `src/analytics/timeline.zig` (pure compute) and
//! `src/history.zig` (snapshot IO). Compare composition is delegated
//! to `src/compare.zig`; compare rendering to `src/views/compare.zig`.
//!
//! Keybinds: see the `Action` enum + `default_bindings` below.

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const history = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig");
const view = @import("../views/history.zig");
const compare_core = @import("../compare.zig");
const compare_view = @import("../views/compare.zig");
const framework = @import("tab_framework.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;

// ── Tab-local action enum ─────────────────────────────────────
//
// History tab keybinds:
//   - `m`               : cycle the focused metric (liquid →
//                         illiquid → net_worth).
//   - `t`               : cycle the resolution (cascading →
//                         daily → weekly → monthly → cascading).
//   - Enter             : expand/collapse the bucket at the cursor
//                         (only meaningful for non-daily, non-live
//                         rows with finer-tier breakdown).
//
// History tab keybinds:
//   - `m`               : cycle the focused metric (liquid →
//                         illiquid → net_worth).
//   - `t`               : cycle the resolution (cascading →
//                         daily → weekly → monthly → cascading).
//   - Enter             : expand/collapse the bucket at the cursor
//                         (only meaningful for non-daily, non-live
//                         rows with finer-tier breakdown).
//   - `s` / space       : toggle inclusion of the cursor row in
//                         the compare-selection set (0, 1, or 2).
//   - `c`               : commit compare (requires 2 rows
//                         selected) or exit an active compare view.
//   - Esc               : cancel compare view or clear pending
//                         selections.

pub const Action = enum {
    /// Toggle expansion of the bucket at the cursor. No-op for
    /// daily, live, or no-children rows. Bound to Enter.
    expand_collapse,
    /// Cycle the focused metric: liquid → illiquid → net_worth → liquid.
    metric_next,
    /// Cycle the resolution: cascading → daily → weekly → monthly → cascading.
    resolution_next,
    /// Toggle inclusion of the cursor row in the compare selection set.
    /// Disabled while a compare view is up. Bound to `s` and space.
    compare_select,
    /// Run compare if exactly 2 rows are selected (otherwise status
    /// hint), or exit an active compare view. Bound to `c`.
    compare_commit,
    /// Cancel compare: drop active compare view if up, else clear
    /// pending selections. No-op when nothing is active. Bound to Esc.
    compare_cancel,
};

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

/// Backing resources for the history tab's active compare view.
///
/// Both endpoints are tracked independently. Snapshot endpoints own
/// their `SnapshotSide` (which includes the snapshot bytes the
/// HoldingMap keys borrow from). Live endpoints own only a
/// `HoldingMap`; the map's keys borrow from `App.portfolio`, which
/// outlives this struct.
///
/// Deinit order is important: the `CompareView` must be deinit'd
/// before these resources, because the view's `symbols` slice contains
/// `SymbolChange.symbol` strings that borrow from one of the maps
/// (the "then" side, per `buildCompareView`).
pub const CompareResources = struct {
    then_snap: ?compare_core.SnapshotSide = null,
    now_snap: ?compare_core.SnapshotSide = null,
    then_live_map: ?compare_view.HoldingMap = null,
    now_live_map: ?compare_view.HoldingMap = null,

    pub fn deinit(self: *CompareResources, allocator: std.mem.Allocator) void {
        if (self.then_snap) |*s| s.deinit(allocator);
        if (self.now_snap) |*s| s.deinit(allocator);
        if (self.then_live_map) |*m| m.deinit();
        if (self.now_live_map) |*m| m.deinit();
    }
};

pub const State = struct {
    /// Whether `activate` has populated the timeline (or set
    /// `disabled`). Distinct from `timeline != null` because
    /// failed/empty fetches still mark loaded.
    loaded: bool = false,
    /// Loaded timeline + series (owned). Null when not loaded
    /// or when the load failed.
    tl: ?history.LoadedTimeline = null,
    /// Cursor position in the recent-snapshots table.
    cursor: usize = 0,
    /// Compare-mode selection slots. Up to two row indices may
    /// be selected; commit-compare requires exactly two.
    selections: [2]?usize = .{ null, null },
    /// Active compare view. When non-null, the history tab
    /// renders a `CompareView` instead of the timeline.
    compare_view: ?compare_view.CompareView = null,
    /// Backing storage for `compare_view`. Owned by State;
    /// freed via `clearCompareView`.
    compare_resources: ?CompareResources = null,
    /// Set during `buildStyledLines`; consumed by the key
    /// handler's cursor-visibility logic and click hit-tests.
    /// First display line of the recent-snapshots table.
    table_first_line: usize = 0,
    /// Number of rows in the recent-snapshots table (post-aggregation).
    table_row_count: usize = 0,
    /// Currently displayed metric (cycles via `metric_next`).
    metric: timeline.Metric = .liquid,
    /// Forced resolution for the history table + chart. Null means
    /// "use the cascading default chosen by `selectResolution`";
    /// the user cycles this with `resolution_next` ('t' by default).
    resolution: ?timeline.Resolution = null,
    /// Per-bucket expansion set. Keyed by `BucketKey` (tier + days)
    /// to disambiguate edge-aligned parents and children. Initialized
    /// in `init` (requires an allocator).
    // SAFETY: overwritten by `init()` before any read; the framework
    // contract guarantees `init` runs before `activate`/draw paths.
    expanded_buckets: std.AutoHashMap(BucketKey, void) = undefined,
};

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

pub const meta: framework.TabMeta(Action) = .{
    .label = "History",
    .default_bindings = &.{
        .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } },
        .{ .action = .metric_next, .key = .{ .codepoint = 'm' } },
        .{ .action = .resolution_next, .key = .{ .codepoint = 't' } },
        .{ .action = .compare_select, .key = .{ .codepoint = 's' } },
        .{ .action = .compare_select, .key = .{ .codepoint = vaxis.Key.space } },
        .{ .action = .compare_commit, .key = .{ .codepoint = 'c' } },
        .{ .action = .compare_cancel, .key = .{ .codepoint = vaxis.Key.escape } },
    },
    .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
        .expand_collapse = "Expand/collapse bucket",
        .metric_next = "Cycle metric",
        .resolution_next = "Cycle resolution",
        .compare_select = "Toggle compare selection",
        .compare_commit = "Compare selected rows",
        .compare_cancel = "Cancel compare",
    }),
    .status_hints = &.{
        .metric_next,
        .resolution_next,
    },
};

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

    pub fn init(state: *State, app: *App) !void {
        state.* = .{
            .expanded_buckets = std.AutoHashMap(BucketKey, void).init(app.allocator),
        };
    }

    pub fn deinit(state: *State, app: *App) void {
        freeLoaded(state, app);
        state.expanded_buckets.deinit();
        state.* = .{};
    }

    pub fn activate(state: *State, app: *App) !void {
        if (state.loaded) return;
        // History reads `app.portfolio.summary` and `.file`,
        // both populated synchronously by pd.load at App init.
        loadData(state, app);
    }

    pub const deactivate = framework.noopDeactivate(State);

    pub fn reload(state: *State, app: *App) !void {
        state.loaded = false;
        freeLoaded(state, app);
        loadData(state, app);
    }

    /// Drop cached timeline and compare view on portfolio reload.
    /// `state.tl` borrows symbol strings from the previous
    /// portfolio's memory, and `compare_resources.then_live_map`
    /// / `now_live_map` borrow keys from `app.portfolio` — all
    /// of which `pd.reload` is about to free. Drop them now.
    ///
    /// Deliberately does NOT eager-rebuild, even when this tab
    /// is active. The reload broadcast fires BEFORE pd.reload
    /// resets the portfolio arena; rebuilding now would store
    /// borrowed pointers that get freed seconds later. The
    /// orchestrator (portfolio_tab.reloadPortfolioFile) calls
    /// `app.loadTabData()` AFTER pd.reload completes, which
    /// re-activates the active tab against fresh data.
    ///
    /// Selections + cursor are reset because the row indices
    /// they refer to may no longer be valid after the new
    /// timeline is loaded (different snapshot count, etc.).
    pub fn onPortfolioReload(state: *State, app: *App) void {
        freeLoaded(state, app);
        state.loaded = false;
        state.cursor = 0;
        state.selections = .{ null, null };
        state.table_first_line = 0;
        state.table_row_count = 0;
        state.expanded_buckets.clearRetainingCapacity();
    }

    pub const tick = framework.noopTick(State);

    pub fn handleAction(state: *State, app: *App, action: Action) void {
        switch (action) {
            .expand_collapse => _ = toggleTierAtCursor(state, app),
            .metric_next => cycleMetric(state),
            .resolution_next => cycleResolution(state),
            .compare_select => {
                // Disabled while a compare view is up — Esc to return first.
                if (state.compare_view != null) return;
                if (state.table_row_count == 0) return;
                toggleSelection(state, app, state.cursor);
            },
            .compare_commit => {
                // If a compare view is up, treat `c` as toggle-off
                // (mirrors Esc semantics on this tab).
                if (state.compare_view != null) {
                    clearCompareView(state, app);
                    clearSelections(state);
                    app.setStatus("");
                    return;
                }
                commitCompare(state, app);
            },
            .compare_cancel => {
                // Esc semantics: drop active compare view if up,
                // else clear pending selections, else no-op.
                if (state.compare_view != null) {
                    clearCompareView(state, app);
                    clearSelections(state);
                    app.setStatus("");
                    return;
                }
                if (selectionCount(state) > 0) {
                    clearSelections(state);
                    app.setStatus("");
                }
            },
        }
    }

    /// History is disabled when no portfolio is loaded — the
    /// history/ subdirectory is derived from the portfolio path.
    /// Same predicate as analysis_tab and projections_tab.
    pub fn isDisabled(app: *App) bool {
        return app.portfolio.file == null;
    }

    /// 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.table_row_count > 0) {
                    state.cursor = state.table_row_count - 1;
                }
            },
        }
    }

    /// Cursor navigation in the recent-snapshots table. Disabled
    /// during compare view mode (Esc/`c` returns first); also no-op
    /// when the table is empty (returns false so the framework
    /// falls through to viewport scroll).
    pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
        if (state.compare_view != null) return false;
        if (state.table_row_count == 0) return false;
        stepCursor(&state.cursor, state.table_row_count, 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;
    }

    /// Mouse handling: a left-click on a row moves the cursor and
    /// toggles tier expansion (no-op for non-tier rows). Returns
    /// `true` if the click landed on a data row in the recent-
    /// snapshots table; `false` otherwise (caller falls through
    /// to global mouse handling — wheel scroll, tab-bar clicks,
    /// etc.). Disabled during compare-view mode.
    pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
        if (mouse.button != .left) return false;
        if (mouse.type != .press) return false;
        if (state.compare_view != null) return false;
        const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
        if (content_row < state.table_first_line) return false;
        if (state.table_row_count == 0) return false;
        const row_idx = content_row - state.table_first_line;
        if (row_idx >= state.table_row_count) return false;
        // Move the cursor to the clicked row, then try to toggle
        // if it's a tier header. Both outcomes consume the click.
        state.cursor = row_idx;
        _ = toggleTierAtCursor(state, app);
        return true;
    }
};

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_line = state.table_first_line + state.cursor;
    if (cursor_line < scroll_offset.*) scroll_offset.* = cursor_line;
    if (cursor_line >= scroll_offset.* + visible_height) {
        scroll_offset.* = cursor_line - visible_height + 1;
    }
}

/// Composite key for `State.expanded_buckets`. Keying by
/// `bucket_start.days` alone collides on edge-aligned parents
/// and children — e.g. yearly 2024 starts on 2024-01-01, and so
/// does its child quarterly Q1 2024. Tagging by tier
/// disambiguates so expanding the parent doesn't auto-expand
/// the child.
pub const BucketKey = struct {
    tier: timeline.Tier,
    days: i32,
};

fn keyFor(b: timeline.TierBucket) BucketKey {
    return .{ .tier = b.tier, .days = b.bucket_start.days };
}

fn keyForRow(row: TableRow) ?BucketKey {
    const t = row.tier orelse return null;
    const start = row.bucket_start orelse return null;
    return .{ .tier = t, .days = start.days };
}

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

pub fn loadData(state: *State, app: *App) void {
    state.loaded = true;
    freeLoaded(state, app);

    const portfolio_path = app.anchorPath() orelse {
        app.setStatus("History tab requires a loaded portfolio");
        return;
    };

    state.tl = history.loadTimeline(app.io, app.allocator, portfolio_path) catch {
        app.setStatus("Failed to read history/ directory");
        return;
    };

    if (state.tl.?.loaded.snapshots.len == 0) {
        freeLoaded(state, app);
        app.setStatus("No snapshots in history/ (run: zfin snapshot)");
    }
}

/// Release the loaded timeline (if any).
pub fn freeLoaded(state: *State, app: *App) void {
    if (state.tl) |*tl| {
        tl.deinit();
        state.tl = null;
    }
    clearCompareView(state, app);
}

/// Clear the compare-view state (selections are preserved).
fn clearCompareView(state: *State, app: *App) void {
    if (state.compare_view) |*cv| {
        cv.deinit(app.allocator);
        state.compare_view = null;
    }
    if (state.compare_resources) |*res| {
        res.deinit(app.allocator);
        state.compare_resources = null;
    }
}

/// Cycle the displayed metric: liquid → illiquid → net_worth → liquid.
pub fn cycleMetric(state: *State) void {
    state.metric = switch (state.metric) {
        .liquid => .illiquid,
        .illiquid => .net_worth,
        .net_worth => .liquid,
    };
}

/// Cycle resolution: cascading → daily → weekly → monthly → cascading.
pub fn cycleResolution(state: *State) void {
    state.resolution = switch (state.resolution orelse {
        // null means "default" — i.e. cascading. Step to daily.
        state.resolution = .daily;
        return;
    }) {
        .cascading => .daily,
        .daily => .weekly,
        .weekly => .monthly,
        .monthly => null, // back to cascading default
    };
}

/// If the cursor is on an expandable (non-daily, non-live)
/// bucket row, toggle its expanded state. Returns true if a
/// toggle happened (so the caller knows to consume the keypress
/// and trigger a redraw).
pub fn toggleTierAtCursor(state: *State, app: *App) bool {
    var arena_state = std.heap.ArenaAllocator.init(app.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const rows = collectTableRows(arena, state, app) catch return false;
    if (state.cursor >= rows.len) return false;
    const row = rows[state.cursor];

    // Only buckets with a finer tier are expandable. Live rows,
    // daily rows, and rows without bucket_start are not.
    if (!row.has_children) return false;
    const key = keyForRow(row) orelse return false;

    if (state.expanded_buckets.contains(key)) {
        _ = state.expanded_buckets.remove(key);
    } else {
        state.expanded_buckets.put(key, {}) catch return false;
    }
    return true;
}

// ── Compare selection model ──────────────────────────────────

/// Returns the number of currently selected rows (0, 1, or 2).
fn selectionCount(state: *const State) usize {
    var n: usize = 0;
    for (state.selections) |s| {
        if (s != null) n += 1;
    }
    return n;
}

/// Returns true if row `idx` is currently selected.
fn isSelected(state: *const State, idx: usize) bool {
    for (state.selections) |s| {
        if (s) |v| if (v == idx) return true;
    }
    return false;
}

/// Toggle selection of row `idx`. With two already selected and this
/// row isn't one of them, sets a status hint and leaves state alone.
fn toggleSelection(state: *State, app: *App, idx: usize) void {
    // Already selected? Remove.
    for (&state.selections) |*slot| {
        if (slot.*) |v| {
            if (v == idx) {
                slot.* = null;
                setSelectionStatus(state, app);
                return;
            }
        }
    }
    // Find an empty slot.
    for (&state.selections) |*slot| {
        if (slot.* == null) {
            slot.* = idx;
            setSelectionStatus(state, app);
            return;
        }
    }
    // Both full, and this row isn't one of them.
    app.setStatus("Two rows already selected — 's' on a selected row to deselect, or 'c' to compare");
}

/// Clear all selections.
fn clearSelections(state: *State) void {
    state.selections = .{ null, null };
}

fn setSelectionStatus(state: *const State, app: *App) void {
    const n = selectionCount(state);
    if (n == 0) {
        app.setStatus("");
        return;
    }
    var arena_state = std.heap.ArenaAllocator.init(app.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return;
    var buf: [128]u8 = undefined;
    const msg = formatSelectionStatus(&buf, n, c_keys[0]) catch return;
    app.setStatus(msg);
}

/// Format the selection-count status message. Pure function over
/// (count, commit_key). Returns a slice of `buf`.
pub fn formatSelectionStatus(buf: []u8, count: usize, commit_key: []const u8) std.fmt.BufPrintError![]const u8 {
    return switch (count) {
        1 => std.fmt.bufPrint(buf, "Selected 1 row — select one more + press '{s}' to compare", .{commit_key}),
        2 => std.fmt.bufPrint(buf, "Selected 2 rows — press '{s}' to compare", .{commit_key}),
        else => unreachable,
    };
}

/// Format the "select N rows" status hint shown by `commitCompare`
/// when the user presses commit with too few rows selected. Pure
/// function over (count, select_key, commit_key).
pub fn formatCompareNeedMore(buf: []u8, count: usize, select_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 {
    return if (count == 0)
        std.fmt.bufPrint(buf, "Select two rows with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key })
    else
        std.fmt.bufPrint(buf, "Select one more row with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key });
}

/// Format the "Comparing — ... to return" status set when a
/// compare view becomes active. Pure function over the two keys.
pub fn formatCompareActiveStatus(buf: []u8, cancel_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 {
    return std.fmt.bufPrint(buf, "Comparing — {s} or '{s}' to return to timeline", .{ cancel_key, commit_key });
}

/// Format the parenthetical key hint shown in the recent-snapshots
/// table header. Pure function over the resolved keys; allocates
/// in `arena`.
pub fn formatTableHeaderHint(
    arena: std.mem.Allocator,
    j_key: []const u8,
    k_key: []const u8,
    enter_key: []const u8,
    select_key: []const u8,
    commit_key: []const u8,
) ![]const u8 {
    return std.fmt.allocPrint(
        arena,
        "({s}/{s}: move, {s}: expand/collapse, {s}/space: select, {s}: compare)",
        .{ j_key, k_key, enter_key, select_key, commit_key },
    );
}

/// Format the footer hint shown at the bottom of an active compare
/// view. Pure function; allocates in `arena`.
pub fn formatCompareFooterHint(
    arena: std.mem.Allocator,
    cancel_key: []const u8,
    commit_key: []const u8,
) ![]const u8 {
    return std.fmt.allocPrint(arena, "  {s} or '{s}' to return to timeline", .{ cancel_key, commit_key });
}

// ── Compare commit ───────────────────────────────────────────

/// Attempt to run compare. Called when the user presses `c`.
/// No-ops (with status hint) if the selection set isn't exactly 2.
fn commitCompare(state: *State, app: *App) void {
    const sel_count = selectionCount(state);
    if (sel_count < 2) {
        var arena_state = std.heap.ArenaAllocator.init(app.allocator);
        defer arena_state.deinit();
        const arena = arena_state.allocator();
        const s_keys = app.keysForTabAction(arena, "history", "compare_select") catch return;
        const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return;
        var buf: [192]u8 = undefined;
        const msg = formatCompareNeedMore(&buf, sel_count, s_keys[0], c_keys[0]) catch return;
        app.setStatus(msg);
        return;
    }

    // At this point both slots are filled.
    const sel_a = state.selections[0].?;
    const sel_b = state.selections[1].?;
    if (sel_a == sel_b) {
        // Shouldn't happen via toggle logic, but guard anyway.
        app.setStatus("Selected rows are the same — clear one and reselect");
        return;
    }

    buildCompareFromSelections(state, app, sel_a, sel_b) catch |err| {
        var msg_buf: [128]u8 = undefined;
        // Translate the most common failure modes into actionable
        // messages. We already short-circuit imported-only rows
        // inside `buildCompareFromSelections`, so reaching the
        // FileNotFound arm here means a snapshot the row claimed
        // to have got moved or deleted between row collection
        // and snapshot load.
        const msg = switch (err) {
            error.FileNotFound => "Compare failed: snapshot file is missing",
            else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed",
        };
        app.setStatus(msg);
        clearCompareView(state, app);
    };
}

/// Build + stash the compare view from two selected row indices.
fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usize) !void {
    // Resolve each row-index into its date and source (live vs
    // snapshot). This requires re-computing the table row list the
    // way the renderer does — shared helper keeps the two paths in
    // sync.
    var arena_state = std.heap.ArenaAllocator.init(app.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const rows = try collectTableRows(arena, state, app);
    if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) {
        app.setStatus("Stale selection — please re-select");
        clearSelections(state);
        return;
    }
    const row_a = rows[sel_a];
    const row_b = rows[sel_b];

    // Order: older → newer.
    const older = if (row_a.date.days < row_b.date.days) row_a else row_b;
    const newer = if (row_a.date.days < row_b.date.days) row_b else row_a;

    // Imported-only rows have no on-disk snapshot — the historical
    // value came from `imported_values.srf`, not a real portfolio
    // snapshot. We can't build a per-symbol compare from that
    // (no lots, no per-symbol prices). Bail out with a friendly
    // status before we try (and fail) to open the snapshot file.
    if (older.imported_only or newer.imported_only) {
        app.setStatus("Cannot compare: imported-only history has no per-symbol detail");
        clearSelections(state);
        return;
    }

    // Build up the resources + maps for each side.
    var resources: CompareResources = .{};
    errdefer resources.deinit(app.allocator);

    const portfolio_path = app.anchorPath() orelse {
        app.setStatus("No portfolio loaded — can't build compare");
        return error.PortfolioLoadFailed;
    };
    const hist_dir = try history.deriveHistoryDir(app.allocator, portfolio_path);
    defer app.allocator.free(hist_dir);

    // SAFETY: assigned in both branches of the `if (older.is_live)`
    // below before first read (via `then_map_ptr.*` when passed to
    // buildCompareView).
    var then_map_ptr: *compare_view.HoldingMap = undefined;
    var then_liquid: f64 = 0;
    // "Then" side.
    if (older.is_live) {
        var map: compare_view.HoldingMap = .init(app.allocator);
        errdefer map.deinit();
        try aggregateFromSummary(app.portfolio.summary.?, &map);
        resources.then_live_map = map;
        then_map_ptr = &resources.then_live_map.?;
        then_liquid = liveLiquid(app);
    } else {
        const side = try compare_core.loadSnapshotSide(app.io, app.allocator, hist_dir, older.date);
        resources.then_snap = side;
        then_map_ptr = &resources.then_snap.?.map;
        then_liquid = side.liquid;
    }

    // SAFETY: assigned in both branches of the `if (newer.is_live)`
    // below before first read.
    var now_map_ptr: *compare_view.HoldingMap = undefined;
    var now_liquid: f64 = 0;
    if (newer.is_live) {
        var map: compare_view.HoldingMap = .init(app.allocator);
        errdefer map.deinit();
        try aggregateFromSummary(app.portfolio.summary.?, &map);
        resources.now_live_map = map;
        now_map_ptr = &resources.now_live_map.?;
        now_liquid = liveLiquid(app);
    } else {
        const side = try compare_core.loadSnapshotSide(app.io, app.allocator, hist_dir, newer.date);
        resources.now_snap = side;
        now_map_ptr = &resources.now_snap.?.map;
        now_liquid = side.liquid;
    }

    // "now is live" only when the NEWER endpoint is the live row.
    const now_is_live = newer.is_live;

    const cv = try compare_view.buildCompareView(
        app.allocator,
        older.date,
        newer.date,
        now_is_live,
        then_liquid,
        now_liquid,
        then_map_ptr,
        now_map_ptr,
    );
    // No error paths between here and install, so no errdefer needed.
    // If buildCompareView returned Ok, we own cv's backing memory
    // (allocated inside buildCompareView) and pass ownership to the App.

    // Bucket-aware labels: when either selected row was a tier
    // bucket (weekly/monthly/quarterly/yearly), render
    // "Q1 2025 (ended 2025-03-28)" instead of the bare ISO date.
    // Allocates into app.allocator; CompareView.deinit frees.
    var cv_with_labels = cv;
    cv_with_labels.then_label = try compare_view.buildBucketLabel(
        app.allocator,
        older.tier,
        older.bucket_start,
        older.date,
        older.is_live,
    );
    cv_with_labels.now_label = try compare_view.buildBucketLabel(
        app.allocator,
        newer.tier,
        newer.bucket_start,
        newer.date,
        newer.is_live,
    );

    // Commit: install both onto State. From this point onwards State
    // owns them and clearCompareView handles teardown.
    clearCompareView(state, app);
    state.compare_view = cv_with_labels;
    state.compare_resources = resources;
    const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
    const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
    var status_buf: [128]u8 = undefined;
    const msg = formatCompareActiveStatus(&status_buf, cancel_keys[0], commit_keys[0]) catch "Comparing — return to timeline";
    app.setStatus(msg);
}

fn liveLiquid(app: *const App) f64 {
    if (app.portfolio.summary) |s| return s.total_value;
    return 0;
}

/// Aggregate the live portfolio's per-symbol holdings from the
/// already-computed `PortfolioSummary.allocations`.
///
/// We derive the post-`price_ratio` per-share price from
/// `market_value / shares` rather than reading raw prices + applying
/// ratio ourselves. This matches the snapshot aggregation (which
/// stores post-ratio prices in its lot rows) and keeps the two
/// endpoints apples-to-apples. Edge case: covered-call adjustments
/// (`adjustForCoveredCalls`) skew the derived per-share price
/// slightly for symbols with ITM sold calls; matches the CLI behavior
/// and is acceptable.
fn aggregateFromSummary(
    summary: zfin.valuation.PortfolioSummary,
    out: *compare_view.HoldingMap,
) !void {
    for (summary.allocations) |a| {
        if (a.shares == 0) continue;
        const price = a.market_value / a.shares;
        try out.put(a.symbol, .{ .shares = a.shares, .price = price });
    }
}

// ── Table row model ──────────────────────────────────────────

/// One row in the rendered recent-snapshots table.
///
/// `is_live` is true for the synthesized "today (live)" pseudo-row
/// at index 0 when the TUI has portfolio-summary state loaded.
///
/// `tier`, when set on a non-header row, identifies which tier
/// the bucket belongs to (used to render a date label like
/// "W of YYYY-MM-DD" / "Q1 YYYY") and to decide drill-down
/// behavior.
///
/// `imported_only` flags rows where illiquid/net_worth are
/// unavailable (data came from imported_values.srf). Renderers
/// substitute `—` for those cells.
///
/// `expanded` and `has_children` drive the per-bucket drill-down.
/// A non-daily bucket can be expanded (clicked / Enter on cursor)
/// to reveal its child buckets at the next-finer tier.
///
/// `indent` is how deep the row sits in the drill-down tree
/// (0 = top-level, 1 = first drill-down level, etc.). The
/// renderer uses this to indent the date column visually.
///
/// `tier_header` is retained for API compatibility but no
/// longer populated by the cascading collector — every row in
/// the new model is a bucket row.
pub const TableRow = struct {
    date: zfin.Date,
    is_live: bool,
    liquid: f64,
    illiquid: f64,
    net_worth: f64,
    d_liquid: ?f64,
    d_illiquid: ?f64,
    d_net_worth: ?f64,
    /// Deprecated — always null in the cascading collector. Kept
    /// for compatibility with older test fixtures.
    tier_header: ?TierHeader = null,
    tier: ?timeline.Tier = null,
    imported_only: bool = false,
    bucket_start: ?zfin.Date = null,
    bucket_end: ?zfin.Date = null,
    /// True when the user has expanded this bucket. Daily and
    /// imported_only-only buckets cannot be expanded.
    expanded: bool = false,
    /// True when this bucket has a finer-tier breakdown
    /// available (any non-daily tier has children).
    has_children: bool = false,
    /// Drill-down depth. 0 for top-level rows; >0 for rows
    /// emitted as children of an expanded bucket.
    indent: u8 = 0,
};

/// Tier header metadata. Rendering displays a chevron + tier
/// label + bucket count.
pub const TierHeader = struct {
    tier: timeline.Tier,
    collapsed: bool,
    bucket_count: u32,
};

/// Build the list of table rows in display order (newest-first).
///
/// Row 0 is the live pseudo-row if available, followed by all snapshot
/// rows from newest to oldest. The `RowDelta` slice from
/// `computeRowDeltas` is oldest-first; we reverse it and optionally
/// prepend the live row.
pub fn collectTableRows(arena: std.mem.Allocator, state: *const State, app: *const App) ![]TableRow {
    const timeline_opt = state.tl;
    if (timeline_opt == null) return &.{};
    const series = timeline_opt.?.series;
    if (series.points.len == 0) return &.{};

    // Resolution dispatch:
    //   - explicit non-cascading: use the legacy flat-aggregation
    //     path (preserves existing behavior for daily/weekly/monthly).
    //   - explicit cascading OR null (default): build the tiered view.
    const explicit = state.resolution;
    const use_cascading = explicit == null or explicit.? == .cascading;

    if (!use_cascading) {
        return collectFlatTableRows(arena, app, series, explicit.?);
    }
    return collectCascadingTableRows(arena, state, app, series);
}

/// Legacy path: flat aggregation by single resolution. Preserved
/// for `--resolution daily/weekly/monthly` invocations.
fn collectFlatTableRows(
    arena: std.mem.Allocator,
    app: *const App,
    series: timeline.TimelineSeries,
    resolution: timeline.Resolution,
) ![]TableRow {
    const aggregated = try timeline.aggregatePoints(arena, series.points, resolution);
    const deltas = try timeline.computeRowDeltas(arena, aggregated);

    // Decide whether to prepend a live pseudo-row. Requires both a
    // portfolio (for illiquid) and a summary (for liquid total).
    const live_opt = buildLiveRow(app, deltas);

    var list: std.ArrayList(TableRow) = .empty;
    try list.ensureTotalCapacity(arena, deltas.len + (if (live_opt == null) @as(usize, 0) else @as(usize, 1)));

    if (live_opt) |live| try list.append(arena, live);

    // deltas is oldest-first; emit newest-first.
    var i: usize = deltas.len;
    while (i > 0) {
        i -= 1;
        const d = deltas[i];
        try list.append(arena, .{
            .date = d.date,
            .is_live = false,
            .liquid = d.liquid,
            .illiquid = d.illiquid,
            .net_worth = d.net_worth,
            .d_liquid = d.d_liquid,
            .d_illiquid = d.d_illiquid,
            .d_net_worth = d.d_net_worth,
        });
    }

    return list.toOwnedSlice(arena);
}

/// New path: cascading aggregation. Produces a flat row list
/// where each top-level bucket is its own row; expanded buckets
/// are followed by their child buckets at the next-finer tier
/// (recursively, so an expanded year reveals quarters which can
/// reveal months which can reveal weeks which can reveal days).
///
/// Daily rows for the last 14 days are top-level (no parent
/// bucket — they're already at leaf granularity).
fn collectCascadingTableRows(
    arena: std.mem.Allocator,
    state: *const State,
    app: *const App,
    series: timeline.TimelineSeries,
) ![]TableRow {
    const ts = try timeline.aggregateCascading(arena, series.points, app.today);

    // Live row (today's live state) — same as flat path.
    const live_opt = buildLiveRowFromCascading(app, ts.buckets);

    var list: std.ArrayList(TableRow) = .empty;
    try list.ensureTotalCapacity(arena, ts.buckets.len + 8);

    if (live_opt) |live| try list.append(arena, live);

    // Pre-compute Δs for the top-level buckets so each row
    // shows "this bucket vs. its older neighbor."
    const top_deltas = try timeline.computeBucketDeltas(arena, ts.buckets);

    for (ts.buckets, 0..) |b, idx| {
        const d = top_deltas[idx];
        const expanded = state.expanded_buckets.contains(keyFor(b));
        try list.append(arena, .{
            .date = b.representative_date,
            .is_live = false,
            .liquid = b.liquid,
            .illiquid = b.illiquid,
            .net_worth = b.net_worth,
            .d_liquid = d.delta_liquid,
            .d_illiquid = d.delta_illiquid,
            .d_net_worth = d.delta_net_worth,
            .tier_header = null,
            .tier = b.tier,
            .imported_only = b.imported_only,
            .bucket_start = b.bucket_start,
            .bucket_end = b.bucket_end,
            .expanded = expanded,
            .has_children = timeline.finerTier(b.tier) != null,
            .indent = 0,
        });

        if (expanded) {
            try emitChildren(arena, state, series.points, b, &list, 1);
        }
    }

    return list.toOwnedSlice(arena);
}

/// Recursively emit child rows for a parent bucket. Used both
/// at top level and when a child is itself expanded.
fn emitChildren(
    arena: std.mem.Allocator,
    state: *const State,
    series: []const timeline.TimelinePoint,
    parent: timeline.TierBucket,
    list: *std.ArrayList(TableRow),
    indent: u8,
) !void {
    const children = try timeline.childBuckets(arena, parent);
    if (children.len == 0) return;
    const child_deltas = try timeline.computeBucketDeltas(arena, children);

    // The oldest child gets a null Δ from `computeBucketDeltas`
    // because there's no older neighbor inside the local slice.
    // But within the wider series there usually IS an older
    // point (just before `parent.bucket_start`); use it so the
    // bottom row shows a meaningful Δ instead of greyed-out null.
    // Only the very-first data point in the entire series should
    // legitimately have a null Δ.
    const oldest_idx = children.len - 1;
    const oldest = children[oldest_idx];
    const prior_opt = priorPointBefore(series, oldest.bucket_start);
    const oldest_delta: timeline.BucketDelta = if (prior_opt) |prior| blk: {
        const dl: ?f64 = oldest.liquid - prior.liquid;
        // Imported-only crossings still null out illiquid /
        // net_worth Δs.
        const cross_imported = oldest.imported_only or prior.source == .imported;
        const di: ?f64 = if (cross_imported) null else oldest.illiquid - prior.illiquid;
        const dn: ?f64 = if (cross_imported) null else oldest.net_worth - prior.net_worth;
        break :blk .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn };
    } else child_deltas[oldest_idx];

    for (children, 0..) |c, idx| {
        const d = if (idx == oldest_idx) oldest_delta else child_deltas[idx];
        const expanded = state.expanded_buckets.contains(keyFor(c));
        try list.append(arena, .{
            .date = c.representative_date,
            .is_live = false,
            .liquid = c.liquid,
            .illiquid = c.illiquid,
            .net_worth = c.net_worth,
            .d_liquid = d.delta_liquid,
            .d_illiquid = d.delta_illiquid,
            .d_net_worth = d.delta_net_worth,
            .tier_header = null,
            .tier = c.tier,
            .imported_only = c.imported_only,
            .bucket_start = c.bucket_start,
            .bucket_end = c.bucket_end,
            .expanded = expanded,
            .has_children = timeline.finerTier(c.tier) != null,
            .indent = indent,
        });

        if (expanded and timeline.finerTier(c.tier) != null) {
            try emitChildren(arena, state, series, c, list, indent + 1);
        }
    }
}

/// Find the most recent series point with `as_of_date < target`.
/// Returns null when `target` precedes every point in the series
/// — i.e., this would be the very first data point overall, the
/// only case where a null Δ is correct.
///
/// `series` is date-ascending (built that way by
/// `timeline.buildMergedSeries`); we walk backward from the end
/// for an early exit on typical drill-down workloads where the
/// target is recent.
fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ?timeline.TimelinePoint {
    var i: usize = series.len;
    while (i > 0) {
        i -= 1;
        if (series[i].as_of_date.days < target.days) return series[i];
    }
    return null;
}

/// Build the live row for the cascading path. Uses the newest
/// bucket (typically a daily bucket) as the comparison anchor.
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
    if (app.portfolio.file == null) return null;
    const summary = app.portfolio.summary orelse return null;

    const liquid = summary.total_value;
    const illiquid = app.portfolio.file.?.totalIlliquid(app.today);
    const net_worth = liquid + illiquid;

    var d_liquid: ?f64 = null;
    var d_illiquid: ?f64 = null;
    var d_net_worth: ?f64 = null;
    if (buckets.len > 0) {
        const b = buckets[0];
        d_liquid = liquid - b.liquid;
        if (!b.imported_only) {
            d_illiquid = illiquid - b.illiquid;
            d_net_worth = net_worth - b.net_worth;
        }
    }

    return .{
        .date = app.today,
        .is_live = true,
        .liquid = liquid,
        .illiquid = illiquid,
        .net_worth = net_worth,
        .d_liquid = d_liquid,
        .d_illiquid = d_illiquid,
        .d_net_worth = d_net_worth,
    };
}

/// Build the synthetic "today (live)" row from App state, or return
/// null if the required state isn't populated.
///
/// Deltas are computed against the newest snapshot in `deltas` (which
/// is index `deltas.len - 1` — deltas is oldest-first).
fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
    if (app.portfolio.file == null) return null;
    const summary = app.portfolio.summary orelse return null;

    const liquid = summary.total_value;
    const illiquid = app.portfolio.file.?.totalIlliquid(app.today);
    const net_worth = liquid + illiquid;

    // Deltas vs. the most recent snapshot.
    var d_liquid: ?f64 = null;
    var d_illiquid: ?f64 = null;
    var d_net_worth: ?f64 = null;
    if (deltas.len > 0) {
        const newest = deltas[deltas.len - 1];
        d_liquid = liquid - newest.liquid;
        d_illiquid = illiquid - newest.illiquid;
        d_net_worth = net_worth - newest.net_worth;
    }

    return .{
        .date = app.today,
        .is_live = true,
        .liquid = liquid,
        .illiquid = illiquid,
        .net_worth = net_worth,
        .d_liquid = d_liquid,
        .d_illiquid = d_illiquid,
        .d_net_worth = d_net_worth,
    };
}

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

pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    // Compare mode short-circuits the timeline render.
    if (state.compare_view) |cv| {
        // Compare view doesn't populate table metadata; reset so the
        // cursor state remains sensible if the user exits back.
        const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel");
        const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
        const footer = try formatCompareFooterHint(arena, cancel_keys[0], commit_keys[0]);
        return renderCompareLines(arena, app.theme, cv, footer);
    }

    const rows = try collectTableRows(arena, state, app);
    // Resolve key bindings for the table-header hint so the displayed
    // keys reflect the user's actual keymap (defaults or overridden).
    const enter_keys = try app.keysForTabAction(arena, "history", "expand_collapse");
    const select_keys = try app.keysForTabAction(arena, "history", "compare_select");
    const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit");
    const j_keys = try app.keysForGlobal(arena, .select_next);
    const k_keys = try app.keysForGlobal(arena, .select_prev);
    const table_hint = try formatTableHeaderHint(
        arena,
        j_keys[0],
        k_keys[0],
        enter_keys[0],
        select_keys[0],
        commit_keys[0],
    );
    const result = try renderHistoryLinesFull(
        arena,
        app.theme,
        if (state.tl) |tl| tl.series else null,
        state.metric,
        state.resolution,
        rows,
        state.cursor,
        state.selections,
        table_hint,
    );

    // Stash table metadata on State for the event handler's cursor-
    // visibility logic and the click hit-test.
    state.table_first_line = result.table_first_line;
    state.table_row_count = result.table_row_count;

    // Clamp cursor in case the row count shrank since the last press
    // (shouldn't happen often but guards against subtle off-by-ones).
    if (result.table_row_count == 0) {
        state.cursor = 0;
    } else if (state.cursor >= result.table_row_count) {
        state.cursor = result.table_row_count - 1;
    }

    return result.lines;
}

/// Thin wrapper so existing tests calling `renderHistoryLines`
/// continue to pass: no cursor, no selections, no live row, no
/// metadata. Internal callers should prefer `renderHistoryLinesFull`.
pub fn renderHistoryLines(
    arena: std.mem.Allocator,
    th: theme.Theme,
    series_opt: ?timeline.TimelineSeries,
    focus_metric: timeline.Metric,
    resolution_override: ?timeline.Resolution,
) ![]const StyledLine {
    const rows = try rowsFromSeries(arena, series_opt, resolution_override);
    const result = try renderHistoryLinesFull(
        arena,
        th,
        series_opt,
        focus_metric,
        resolution_override,
        rows,
        0,
        .{ null, null },
        null,
    );
    return result.lines;
}

fn rowsFromSeries(
    arena: std.mem.Allocator,
    series_opt: ?timeline.TimelineSeries,
    resolution_override: ?timeline.Resolution,
) ![]TableRow {
    const series = series_opt orelse return &.{};
    if (series.points.len == 0) return &.{};
    const resolution = resolution_override orelse timeline.selectResolution(series.points);
    const aggregated = try timeline.aggregatePoints(arena, series.points, resolution);
    const deltas = try timeline.computeRowDeltas(arena, aggregated);

    var list: std.ArrayList(TableRow) = .empty;
    try list.ensureTotalCapacity(arena, deltas.len);
    var i: usize = deltas.len;
    while (i > 0) {
        i -= 1;
        const d = deltas[i];
        try list.append(arena, .{
            .date = d.date,
            .is_live = false,
            .liquid = d.liquid,
            .illiquid = d.illiquid,
            .net_worth = d.net_worth,
            .d_liquid = d.d_liquid,
            .d_illiquid = d.d_illiquid,
            .d_net_worth = d.d_net_worth,
        });
    }
    return list.toOwnedSlice(arena);
}

/// Full render result: the styled lines plus metadata the event
/// handler needs for cursor-visibility logic.
pub const HistoryRender = struct {
    lines: []const StyledLine,
    /// Line index of the first data row in the recent-snapshots table.
    /// Zero when the table isn't rendered (no data).
    table_first_line: usize,
    /// Number of data rows in the table (includes live pseudo-row).
    table_row_count: usize,
};

/// Full renderer: takes cursor + selections + prebuilt rows (already
/// in display order, newest-first) and returns styled lines plus
/// cursor metadata. Pure — no App dependency — so tests can drive it.
pub fn renderHistoryLinesFull(
    arena: std.mem.Allocator,
    th: theme.Theme,
    series_opt: ?timeline.TimelineSeries,
    focus_metric: timeline.Metric,
    resolution_override: ?timeline.Resolution,
    rows: []const TableRow,
    cursor: usize,
    selections: [2]?usize,
    /// Optional override for the parenthetical key hints in the
    /// "Recent snapshots" header. When null, falls back to the
    /// default-keymap literal. Pass an arena-allocated string from
    /// `buildStyledLines` for live-resolved keys.
    table_header_hint: ?[]const u8,
) !HistoryRender {
    var lines: std.ArrayList(StyledLine) = .empty;

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

    const series = series_opt orelse {
        try lines.append(arena, .{ .text = "  No history snapshots yet.", .style = th.mutedStyle() });
        try lines.append(arena, .{ .text = "  Run: zfin snapshot (or wait for the daily cron)", .style = th.mutedStyle() });
        return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 };
    };

    const points = series.points;
    if (points.len == 0) {
        try lines.append(arena, .{ .text = "  No snapshots found.", .style = th.mutedStyle() });
        return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 };
    }

    const metric_label = focus_metric.label();

    // ── Windows block (focused metric only) ──────────────────────
    try appendWindowsBlock(arena, &lines, th, points, focus_metric, metric_label);
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // ── Chart ────────────────────────────────────────────────────
    const chart_header = try std.fmt.allocPrint(
        arena,
        "  Chart: {s}   (press 'm' to cycle metric, 't' to cycle resolution)",
        .{metric_label},
    );
    try lines.append(arena, .{ .text = chart_header, .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Chart: synthesize candles from the focused metric's value.
    // For illiquid / net_worth, skip imported-only points so the
    // line is visually absent in the imported-only range rather
    // than hugging zero.
    var candles_list: std.ArrayList(zfin.Candle) = .empty;
    try candles_list.ensureTotalCapacity(arena, points.len);
    const skip_imported = (focus_metric == .illiquid) or (focus_metric == .net_worth);
    for (points) |p| {
        if (skip_imported and p.source == .imported) continue;
        const value = extractOne(p, focus_metric);
        try candles_list.append(arena, .{
            .date = p.as_of_date,
            .open = value,
            .high = value,
            .low = value,
            .close = value,
            .adj_close = value,
            .volume = 0,
        });
    }
    const candles = candles_list.items;
    try tui.renderBrailleToStyledLines(arena, &lines, candles, th);

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

    // ── Recent snapshots table ───────────────────────────────────
    // Resolve the displayed resolution label: explicit override
    // wins, otherwise default is cascading (matches the
    // rendering in `collectTableRows`).
    const resolution: timeline.Resolution = if (resolution_override) |r| r else .cascading;

    var rlabel_buf: [32]u8 = undefined;
    const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
    const hint_text = table_header_hint orelse "(j/k: move, enter: expand/collapse, s/space: select, c: compare)";
    const table_header = try std.fmt.allocPrint(
        arena,
        "  Recent snapshots {s}   {s}",
        .{ rlabel, hint_text },
    );
    try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Column header: extra 2-char left margin for the selection
    // marker; "Date" itself indented 2 more chars so the column
    // text aligns with bucket date labels (which carry a 2-space
    // chevron-or-spacer prefix). 28-col slot matches `fmtTableRow`.
    const header_line = try std.fmt.allocPrint(
        arena,
        "    {s:<28}  {s:>31}  {s:>31}  {s:>31}",
        .{ "  Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
    );
    try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });

    const table_first_line = lines.items.len;

    for (rows, 0..) |row, idx| {
        const is_cursor = idx == cursor;
        const selected = isIndexSelected(selections, idx);

        const text = try fmtTableRow(arena, row, selected);
        const style = if (is_cursor)
            th.selectStyle()
        else if (selected)
            th.headerStyle()
        else
            rowStyle(th, row, focus_metric);

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

    return .{
        .lines = try lines.toOwnedSlice(arena),
        .table_first_line = table_first_line,
        .table_row_count = rows.len,
    };
}

fn isIndexSelected(selections: [2]?usize, idx: usize) bool {
    for (selections) |s| {
        if (s) |v| if (v == idx) return true;
    }
    return false;
}

/// Render the rolling-windows block into `lines`. Output matches the
/// CLI byte-for-byte (modulo ANSI) — same widths, same labels, same
/// dashed divider — because both call `view.buildWindowRowCells`.
fn appendWindowsBlock(
    arena: std.mem.Allocator,
    lines: *std.ArrayList(StyledLine),
    th: theme.Theme,
    points: []const timeline.TimelinePoint,
    metric: timeline.Metric,
    metric_label: []const u8,
) !void {
    _ = metric_label;

    const today = points[points.len - 1].as_of_date;
    const ws = try timeline.computeWindowSet(arena, points, metric, today);

    try lines.append(arena, .{ .text = "  Change (snapshot-to-snapshot Δ)", .style = th.headerStyle() });

    const header_line = try std.fmt.allocPrint(
        arena,
        "  {s:<12}  {s:>18}  {s:>10}  {s:>10}",
        .{ "", "Δ", "%", "% / yr" },
    );
    try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
    try lines.append(arena, .{
        .text = "  ------------  ------------------  ----------  ----------",
        .style = th.mutedStyle(),
    });

    for (ws.rows) |row| {
        var dbuf: [32]u8 = undefined;
        var pbuf: [16]u8 = undefined;
        var abuf: [16]u8 = undefined;
        const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);

        const text = try std.fmt.allocPrint(
            arena,
            "  {s:<12}  {s:>18}  {s:>10}  {s:>10}",
            .{ cells.label, cells.delta_str, cells.pct_str, cells.ann_str },
        );
        const style: vaxis.Cell.Style = th.styleFor(cells.style);
        try lines.append(arena, .{ .text = text, .style = style });
    }
}

/// Build a recent-snapshots table row. `selected` causes a `*` marker
/// to appear in the two-column left margin instead of spaces.
///
/// All rows align column-for-column regardless of drill-down depth:
/// the value cells (Liquid/Illiquid/Net Worth) sit at fixed column
/// positions so their closing `)` characters line up vertically
/// across the whole table.
///
/// Drill-down depth is conveyed inside the date column only — by
/// indenting the chevron+label within the 20-byte date slot. The
/// trade-off: if a deeply-drilled label overflows 20 bytes (e.g.
/// `      ▶ W of 2024-03-31`), the value columns shift right just
/// for that row. We mitigate by capping the per-level indent at
/// 2 columns and shrinking the slot use.
fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 {
    // Legacy: tier_header rows (now unused). Kept so old test
    // fixtures still render something sensible.
    if (row.tier_header) |th| {
        const chevron: []const u8 = if (th.collapsed) "▶" else "▼";
        return std.fmt.allocPrint(
            arena,
            "  {s} {s} ({d})",
            .{ chevron, @tagName(th.tier), th.bucket_count },
        );
    }

    var date_buf: [40]u8 = undefined;
    var liq_cell_buf: [64]u8 = undefined;
    var ill_cell_buf: [64]u8 = undefined;
    var nw_cell_buf: [64]u8 = undefined;

    // Build the date cell content. Drill-down depth is shown by
    // leading spaces *inside* the date slot, NOT by indenting
    // the whole row — so values stay aligned column-for-column.
    const date_s: []const u8 = if (row.is_live)
        // Write into `date_buf` so the in-place padding below
        // can extend it.
        std.fmt.bufPrint(&date_buf, "  today", .{}) catch "  today"
    else if (row.tier) |t| blk: {
        var inner_buf: [32]u8 = undefined;
        // Bucket rows from cascading mode carry `bucket_start`;
        // legacy daily rows from flat-resolution don't, so we
        // fall back to `row.date` (the row's representative
        // date — same value as bucket_start for daily buckets).
        const start = row.bucket_start orelse row.date;
        const lbl = timeline.formatBucketLabel(&inner_buf, t, start);
        // Compute indent prefix: 2 spaces per indent level, capped
        // at 16 to keep the date cell from blowing past its 28-col
        // budget. (Trees go 4-5 levels max in practice.)
        const indent_pool = "                "; // 16 spaces
        const indent_cols = @min(@as(usize, row.indent) * 2, indent_pool.len);
        const lead = indent_pool[0..indent_cols];
        if (row.has_children) {
            const chev: []const u8 = if (row.expanded) "▼" else "▶";
            break :blk std.fmt.bufPrint(
                &date_buf,
                "{s}{s} {s}",
                .{ lead, chev, lbl },
            ) catch lbl;
        }
        // No expand chevron: 2 leading spaces (to align with
        // chevron-bearing rows at the same indent level).
        break :blk std.fmt.bufPrint(
            &date_buf,
            "{s}  {s}",
            .{ lead, lbl },
        ) catch lbl;
    } else blk: {
        break :blk std.fmt.bufPrint(&date_buf, "  {f}", .{row.date}) catch "????-??-??";
    };

    const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width);
    const ill_cell = if (row.imported_only)
        fmt.centerDash(&ill_cell_buf, view.table_cell_width)
    else
        view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width);
    const nw_cell = if (row.imported_only)
        fmt.centerDash(&nw_cell_buf, view.table_cell_width)
    else
        view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width);

    const marker: []const u8 = if (selected) "* " else "  ";
    // Pad date_s to a consistent *display* width of 28 columns
    // (wide enough to fit the deepest drilldown label, which is
    // `      ▶ W of YYYY-MM-DD` ~ 25 cols when fully drilled).
    // Zig's `{s:<N}` pads by bytes, which under-pads rows that
    // contain the multi-byte chevron (▶/▼ are 3 bytes / 1 col);
    // `fmt.padRightToCols` accounts for display width.
    //
    // `date_s` already lives at the start of `date_buf`, so the
    // in-place pad just appends spaces after it.
    const date_padded = fmt.padRightToCols(&date_buf, date_s, 28);
    return std.fmt.allocPrint(
        arena,
        "  {s}{s}  {s}  {s}  {s}",
        .{ marker, date_padded, liq_cell, ill_cell, nw_cell },
    );
}

fn rowStyle(th: theme.Theme, row: TableRow, metric: timeline.Metric) vaxis.Cell.Style {
    if (row.tier_header != null) return th.mutedStyle();
    const d_opt: ?f64 = switch (metric) {
        .liquid => row.d_liquid,
        .illiquid => row.d_illiquid,
        .net_worth => row.d_net_worth,
    };
    if (d_opt) |d| {
        if (d < 0) return th.negativeStyle();
        if (d > 0) return th.positiveStyle();
    }
    return th.mutedStyle();
}

fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 {
    return switch (metric) {
        .net_worth => p.net_worth,
        .liquid => p.liquid,
        .illiquid => p.illiquid,
    };
}

// ── Compare-mode rendering ────────────────────────────────────
//
// Thin adapter: pulls pre-formatted cells from `views/compare.zig`
// and drops them into vaxis-styled lines. Layout widths, number
// formatting, and label pluralization all come from the view layer —
// this function owns only the TUI-specific style mapping (via
// `theme.styleFor`) and the "  " leading indent that matches the
// rest of the history tab. The CLI renderer in `commands/compare.zig`
// uses the same shared cells + format constants, so the two outputs
// stay in lockstep.

/// Render a `CompareView` as a sequence of styled lines for the TUI.
/// Allocates all line text into `arena`; strings borrow from it.
pub fn renderCompareLines(
    arena: std.mem.Allocator,
    th: theme.Theme,
    cv: compare_view.CompareView,
    footer_hint: []const u8,
) ![]const StyledLine {
    var lines: std.ArrayList(StyledLine) = .empty;

    // Spacer matching the rest of the tab for layout consistency
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // ── Header ──
    var then_buf: [10]u8 = undefined;
    var now_buf: [24]u8 = undefined;
    const then_iso = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??";
    const now_iso = compare_view.nowLabel(cv, &now_buf);

    // Prefer bucketed labels (e.g. "Q1 2025 (ended 2025-03-28)")
    // when the caller supplied them; fall back to plain ISO dates.
    const then_str = cv.then_label orelse then_iso;
    const now_str = cv.now_label orelse now_iso;

    const header = try std.fmt.allocPrint(
        arena,
        "  Portfolio comparison: {s} → {s} ({d} day{s})",
        .{ then_str, now_str, cv.days_between, compare_view.dayPlural(cv.days_between) },
    );
    try lines.append(arena, .{ .text = header, .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // ── Totals line ──
    {
        var t_then: [24]u8 = undefined;
        var t_now: [24]u8 = undefined;
        var t_delta: [32]u8 = undefined;
        var t_pct: [16]u8 = undefined;
        const t = compare_view.buildTotalsCells(cv.liquid, &t_then, &t_now, &t_delta, &t_pct);

        const totals_text = try std.fmt.allocPrint(
            arena,
            "  Liquid:  {s}{s}{s}    {s}   {s}",
            .{ t.then, compare_view.arrow, t.now, t.delta, t.pct },
        );
        try lines.append(arena, .{ .text = totals_text, .style = th.styleFor(t.style) });
    }

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

    // ── Per-symbol table ──
    if (cv.held_count == 0) {
        try lines.append(arena, .{
            .text = "  No symbols held throughout this period.",
            .style = th.mutedStyle(),
        });
    } else {
        const subtitle = try std.fmt.allocPrint(
            arena,
            "  Per-symbol price change ({d} held throughout)",
            .{cv.held_count},
        );
        try lines.append(arena, .{ .text = subtitle, .style = th.mutedStyle() });

        for (cv.symbols) |s| {
            var p_then: [24]u8 = undefined;
            var p_now: [24]u8 = undefined;
            var p_pct: [16]u8 = undefined;
            var p_dollar: [32]u8 = undefined;
            const c = compare_view.buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);

            // Single-color per row using the shared row template.
            const row_text = try std.fmt.allocPrint(
                arena,
                "  " ++ compare_view.symbol_row_fmt,
                .{ c.symbol, c.price_then, compare_view.arrow, c.price_now, c.pct, c.dollar },
            );
            try lines.append(arena, .{ .text = row_text, .style = th.styleFor(c.style) });
        }
    }

    // ── Hidden count ──
    if (cv.added_count > 0 or cv.removed_count > 0) {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        const hidden_text = try std.fmt.allocPrint(
            arena,
            "  ({d} added, {d} removed since {s} — hidden)",
            .{ cv.added_count, cv.removed_count, then_str },
        );
        try lines.append(arena, .{ .text = hidden_text, .style = th.mutedStyle() });
    }

    // ── Footer hint ──
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = footer_hint,
        .style = th.mutedStyle(),
    });

    return lines.toOwnedSlice(arena);
}

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

const testing = std.testing;
const Date = zfin.Date;
const snapshot = @import("../models/snapshot.zig");

test "formatSelectionStatus: count 1 includes commit key" {
    var buf: [128]u8 = undefined;
    const msg = try formatSelectionStatus(&buf, 1, "c");
    try testing.expectEqualStrings("Selected 1 row — select one more + press 'c' to compare", msg);
}

test "formatSelectionStatus: count 2 includes commit key" {
    var buf: [128]u8 = undefined;
    const msg = try formatSelectionStatus(&buf, 2, "X");
    try testing.expectEqualStrings("Selected 2 rows — press 'X' to compare", msg);
}

test "formatCompareNeedMore: count 0 says 'two rows'" {
    var buf: [192]u8 = undefined;
    const msg = try formatCompareNeedMore(&buf, 0, "s", "c");
    try testing.expectEqualStrings("Select two rows with 's' (or space), then press 'c' to compare", msg);
}

test "formatCompareNeedMore: count 1 says 'one more row'" {
    var buf: [192]u8 = undefined;
    const msg = try formatCompareNeedMore(&buf, 1, "x", "y");
    try testing.expectEqualStrings("Select one more row with 'x' (or space), then press 'y' to compare", msg);
}

test "formatCompareActiveStatus: includes both keys" {
    var buf: [128]u8 = undefined;
    const msg = try formatCompareActiveStatus(&buf, "Esc", "c");
    try testing.expectEqualStrings("Comparing — Esc or 'c' to return to timeline", msg);
}

test "formatTableHeaderHint: includes all five keys" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const msg = try formatTableHeaderHint(arena, "j", "k", "enter", "s", "c");
    try testing.expectEqualStrings("(j/k: move, enter: expand/collapse, s/space: select, c: compare)", msg);
}

test "formatTableHeaderHint: respects rebound keys" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const msg = try formatTableHeaderHint(arena, "down", "up", "space", "x", "y");
    try testing.expectEqualStrings("(down/up: move, space: expand/collapse, x/space: select, y: compare)", msg);
}

test "formatCompareFooterHint: standard format" {
    var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const msg = try formatCompareFooterHint(arena, "Esc", "c");
    try testing.expectEqualStrings("  Esc or 'c' to return to timeline", msg);
}

test "renderHistoryLines: no series shows no-data message" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const lines = try renderHistoryLines(a, th, null, .liquid, null);
    var saw_no_data = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "No history snapshots") != null) saw_no_data = true;
    }
    try testing.expect(saw_no_data);
}

test "renderHistoryLines: renders windows + chart + table in correct order" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const pts = try a.alloc(timeline.TimelinePoint, 2);
    pts[0] = .{
        .as_of_date = Date.fromYmd(2024, 3, 14),
        .net_worth = 1000,
        .liquid = 700,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    pts[1] = .{
        .as_of_date = Date.fromYmd(2024, 3, 15),
        .net_worth = 1100,
        .liquid = 800,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };

    const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
    const lines = try renderHistoryLines(a, th, series, .liquid, .daily);

    var windows_idx: ?usize = null;
    var chart_idx: ?usize = null;
    var table_idx: ?usize = null;
    for (lines, 0..) |l, i| {
        if (std.mem.indexOf(u8, l.text, "Change") != null) windows_idx = i;
        if (std.mem.indexOf(u8, l.text, "Chart: Liquid") != null) chart_idx = i;
        if (std.mem.indexOf(u8, l.text, "Recent snapshots") != null) table_idx = i;
    }
    try testing.expect(windows_idx != null);
    try testing.expect(chart_idx != null);
    try testing.expect(table_idx != null);
    try testing.expect(windows_idx.? < chart_idx.?);
    try testing.expect(chart_idx.? < table_idx.?);
}

test "renderHistoryLines: windows block includes 1 day + All-time" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const pts = try a.alloc(timeline.TimelinePoint, 2);
    pts[0] = .{
        .as_of_date = Date.fromYmd(2024, 3, 14),
        .net_worth = 1000,
        .liquid = 700,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    pts[1] = .{
        .as_of_date = Date.fromYmd(2024, 3, 15),
        .net_worth = 1100,
        .liquid = 800,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };

    const lines = try renderHistoryLines(a, th, series, .liquid, null);

    var saw_1d = false;
    var saw_all_time = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "1 day") != null) saw_1d = true;
        if (std.mem.indexOf(u8, l.text, "All-time") != null) saw_all_time = true;
    }
    try testing.expect(saw_1d);
    try testing.expect(saw_all_time);
}

test "renderHistoryLines: table rows emitted newest-first and column order is Liquid → Illiquid → NW" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const pts = try a.alloc(timeline.TimelinePoint, 3);
    pts[0] = .{
        .as_of_date = Date.fromYmd(2024, 3, 13),
        .net_worth = 900,
        .liquid = 600,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    pts[1] = .{
        .as_of_date = Date.fromYmd(2024, 3, 14),
        .net_worth = 1000,
        .liquid = 700,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    pts[2] = .{
        .as_of_date = Date.fromYmd(2024, 3, 15),
        .net_worth = 1100,
        .liquid = 800,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };

    const lines = try renderHistoryLines(a, th, series, .liquid, .daily);

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

    const h_liq = std.mem.indexOf(u8, text, "Liquid") orelse return error.TestExpectedMatch;
    const h_ill = std.mem.indexOf(u8, text, "Illiquid") orelse return error.TestExpectedMatch;
    const h_nw = std.mem.indexOf(u8, text, "Net Worth") orelse return error.TestExpectedMatch;
    try testing.expect(h_liq < h_ill);
    try testing.expect(h_ill < h_nw);

    const d_new = std.mem.lastIndexOf(u8, text, "2024-03-15") orelse return error.TestExpectedMatch;
    const d_old = std.mem.lastIndexOf(u8, text, "2024-03-13") orelse return error.TestExpectedMatch;
    try testing.expect(d_new < d_old);
}

test "renderHistoryLines: metric cycling changes chart label and windows header" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const pts = try a.alloc(timeline.TimelinePoint, 1);
    pts[0] = .{
        .as_of_date = Date.fromYmd(2024, 3, 15),
        .net_worth = 100,
        .liquid = 60,
        .illiquid = 40,
        .accounts = &.{},
        .tax_types = &.{},
    };
    const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };

    const lines_ill = try renderHistoryLines(a, th, series, .illiquid, null);
    var saw_ill_chart = false;
    for (lines_ill) |l| {
        if (std.mem.indexOf(u8, l.text, "Chart: Illiquid") != null) saw_ill_chart = true;
    }
    try testing.expect(saw_ill_chart);
}

test "cycleMetric: liquid → illiquid → net_worth → liquid" {
    var m: timeline.Metric = .liquid;
    m = switch (m) {
        .liquid => .illiquid,
        .illiquid => .net_worth,
        .net_worth => .liquid,
    };
    try testing.expectEqual(timeline.Metric.illiquid, m);
    m = switch (m) {
        .liquid => .illiquid,
        .illiquid => .net_worth,
        .net_worth => .liquid,
    };
    try testing.expectEqual(timeline.Metric.net_worth, m);
    m = switch (m) {
        .liquid => .illiquid,
        .illiquid => .net_worth,
        .net_worth => .liquid,
    };
    try testing.expectEqual(timeline.Metric.liquid, m);
}

test "renderHistoryLinesFull: cursor highlights selected row" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const pts = try a.alloc(timeline.TimelinePoint, 3);
    pts[0] = .{
        .as_of_date = Date.fromYmd(2024, 3, 13),
        .net_worth = 900,
        .liquid = 600,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    pts[1] = .{
        .as_of_date = Date.fromYmd(2024, 3, 14),
        .net_worth = 1000,
        .liquid = 700,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    pts[2] = .{
        .as_of_date = Date.fromYmd(2024, 3, 15),
        .net_worth = 1100,
        .liquid = 800,
        .illiquid = 300,
        .accounts = &.{},
        .tax_types = &.{},
    };
    const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
    const rows = try rowsFromSeries(a, series, .daily);

    // Two rows selected (idx 0 and 2), cursor on idx 1.
    const result = try renderHistoryLinesFull(
        a,
        th,
        series,
        .liquid,
        .daily,
        rows,
        1,
        .{ 0, 2 },
        null,
    );

    try testing.expectEqual(@as(usize, 3), result.table_row_count);
    try testing.expect(result.table_first_line > 0);

    // Check that the first row (selected, not cursor) has the marker
    // character in its text at the expected position.
    const first_row_line = result.lines[result.table_first_line];
    try testing.expect(std.mem.indexOf(u8, first_row_line.text, "* ") != null);

    // Third row is selected too.
    const third_row_line = result.lines[result.table_first_line + 2];
    try testing.expect(std.mem.indexOf(u8, third_row_line.text, "* ") != null);

    // Second row (cursor) has no marker — just the cursor style.
    const second_row_line = result.lines[result.table_first_line + 1];
    try testing.expect(std.mem.indexOf(u8, second_row_line.text, "* ") == null);
}

// ── renderCompareLines tests ──────────────────────────────────

test "renderCompareLines: emits header, totals, symbols, hidden-count, footer" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const symbols = [_]compare_view.SymbolChange{
        .{
            .symbol = "FOO",
            .price_then = 100,
            .price_now = 110,
            .shares_held_throughout = 10,
            .pct_change = 0.10,
            .dollar_change = 100,
            .style = .positive,
        },
    };
    const cv = compare_view.CompareView{
        .then_date = Date.fromYmd(2024, 1, 15),
        .now_date = Date.fromYmd(2024, 3, 15),
        .days_between = 60,
        .now_is_live = false,
        .liquid = compare_view.buildTotalsRow(10_000, 10_500),
        .symbols = @constCast(&symbols),
        .held_count = 1,
        .added_count = 1,
        .removed_count = 0,
    };

    const lines = try renderCompareLines(a, th, cv, "  Esc or 'c' to return to timeline");

    var saw_header = false;
    var saw_totals = false;
    var saw_row = false;
    var saw_hidden = false;
    var saw_footer = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "Portfolio comparison: 2024-01-15 → 2024-03-15") != null) saw_header = true;
        if (std.mem.indexOf(u8, l.text, "Liquid:") != null and std.mem.indexOf(u8, l.text, "$10,000.00") != null) saw_totals = true;
        if (std.mem.indexOf(u8, l.text, "FOO") != null and std.mem.indexOf(u8, l.text, "+10.00%") != null) saw_row = true;
        if (std.mem.indexOf(u8, l.text, "1 added, 0 removed since 2024-01-15") != null) saw_hidden = true;
        if (std.mem.indexOf(u8, l.text, "Esc") != null) saw_footer = true;
    }
    try testing.expect(saw_header);
    try testing.expect(saw_totals);
    try testing.expect(saw_row);
    try testing.expect(saw_hidden);
    try testing.expect(saw_footer);
}

test "renderCompareLines: live-now shows date with (live) marker" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const cv = compare_view.CompareView{
        .then_date = Date.fromYmd(2024, 1, 15),
        .now_date = Date.fromYmd(2024, 3, 15),
        .days_between = 60,
        .now_is_live = true,
        .liquid = compare_view.buildTotalsRow(100, 105),
        .symbols = &.{},
        .held_count = 0,
        .added_count = 0,
        .removed_count = 0,
    };
    const lines = try renderCompareLines(a, th, cv, "  Esc or 'c' to return to timeline");

    var saw_live_marker = false;
    var saw_no_symbols = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "2024-03-15 (live)") != null) saw_live_marker = true;
        if (std.mem.indexOf(u8, l.text, "No symbols held throughout") != null) saw_no_symbols = true;
    }
    try testing.expect(saw_live_marker);
    try testing.expect(saw_no_symbols);
}

test "renderCompareLines: no hidden line when no add/remove" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const cv = compare_view.CompareView{
        .then_date = Date.fromYmd(2024, 1, 15),
        .now_date = Date.fromYmd(2024, 3, 15),
        .days_between = 60,
        .now_is_live = false,
        .liquid = compare_view.buildTotalsRow(100, 100),
        .symbols = &.{},
        .held_count = 0,
        .added_count = 0,
        .removed_count = 0,
    };
    const lines = try renderCompareLines(a, th, cv, "  Esc or 'c' to return to timeline");

    for (lines) |l| {
        try testing.expect(std.mem.indexOf(u8, l.text, "hidden") == null);
    }
}

// Keep refAllDeclsRecursive happy
test {
    _ = snapshot;
}

test "renderCompareLines: bucket labels override ISO dates in header" {
    var arena = std.heap.ArenaAllocator.init(testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const th = theme.default_theme;

    const cv = compare_view.CompareView{
        .then_date = Date.fromYmd(2025, 3, 28),
        .now_date = Date.fromYmd(2026, 5, 8),
        .days_between = 406,
        .now_is_live = false,
        .liquid = compare_view.buildTotalsRow(6_000_000, 8_000_000),
        .symbols = &.{},
        .held_count = 0,
        .added_count = 0,
        .removed_count = 0,
        .then_label = "Q1 2025 (ended 2025-03-28)",
        .now_label = null,
    };
    const lines = try renderCompareLines(a, th, cv, "  Esc or 'c' to return to timeline");

    var found_header = false;
    for (lines) |l| {
        if (std.mem.indexOf(u8, l.text, "Q1 2025 (ended 2025-03-28)") != null and
            std.mem.indexOf(u8, l.text, "→ 2026-05-08") != null)
        {
            found_header = true;
            break;
        }
    }
    try testing.expect(found_header);
}

test "priorPointBefore: returns null for the very first data point" {
    const points = [_]timeline.TimelinePoint{
        .{
            .as_of_date = Date.fromYmd(2014, 7, 3),
            .net_worth = 1_280_000,
            .liquid = 1_280_000,
            .illiquid = 0,
            .accounts = &.{},
            .tax_types = &.{},
            .source = .imported,
        },
        .{
            .as_of_date = Date.fromYmd(2015, 7, 3),
            .net_worth = 1_500_000,
            .liquid = 1_500_000,
            .illiquid = 0,
            .accounts = &.{},
            .tax_types = &.{},
            .source = .imported,
        },
    };
    // Target preceding all data → null.
    try testing.expectEqual(@as(?timeline.TimelinePoint, null), priorPointBefore(&points, Date.fromYmd(2014, 1, 1)));
    // Target after first → finds the first point.
    const got = priorPointBefore(&points, Date.fromYmd(2015, 1, 1));
    try testing.expect(got != null);
    try testing.expect(got.?.as_of_date.eql(Date.fromYmd(2014, 7, 3)));
    // Target after both → finds the latest.
    const got2 = priorPointBefore(&points, Date.fromYmd(2026, 1, 1));
    try testing.expect(got2 != null);
    try testing.expect(got2.?.as_of_date.eql(Date.fromYmd(2015, 7, 3)));
}
