//! TUI projections tab — retirement projections and benchmark comparison.
//!
//! Layout (top-to-bottom):
//!   1. Benchmark comparison table (SPY/AGG/Benchmark/Your Portfolio)
//!   2. Conservative estimate + target allocation note
//!   3. Braille chart of portfolio value percentile bands (median line)
//!   4. Terminal portfolio value table (p10/p50/p90)
//!   5. Safe withdrawal table at multiple confidence levels
//!
//! Consumes `src/analytics/projections.zig` (simulation engine),
//! `src/analytics/benchmark.zig` (weighted returns), and
//! `src/views/projections.zig` (view model).
//!
//! ## As-of mode
//!
//! When `state.as_of` is non-null, the tab renders against a
//! historical snapshot instead of the live portfolio, using
//! `view.loadProjectionContextAsOf`. The user toggles this via the `d`
//! keybind (date popup) or `D` (return to live). Auto-snaps to the
//! nearest earlier snapshot when the exact date isn't available.

const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projection_chart = @import("projection_chart.zig");
const forecast_chart = @import("forecast_chart.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const cli = @import("../commands/common.zig");
const framework = @import("tab_framework.zig");
const input_buffer = @import("input_buffer.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;

// ── Tab-local action enum ─────────────────────────────────────
//
// Projections tab keybinds:
//   - `o` : toggle the actuals overlay on the projection chart
//   - `v` : show/hide the percentile-band chart
//   - `e` : enable/disable simulated lifecycle events
//   - `d` : open the as-of date input popup
//   - Esc : clear the active as-of date (back to live view)

pub const Action = enum {
    /// Toggle the as-of-anchored actuals overlay on the projection
    /// chart. No-op (with status hint) when not in as-of mode.
    overlay_actuals,
    /// Show / hide the percentile-band chart. Toggles
    /// `state.chart_visible`; doesn't reload data.
    toggle_chart,
    /// Enable / disable simulated lifecycle events (RMDs, lump-sum
    /// withdrawals). Forces a reload because the simulation engine
    /// re-runs with the new flag.
    toggle_events,
    /// Open the as-of date input mini-popup. Mode transition is
    /// handled by the App; the popup commit / clear path lives in
    /// tui.zig and calls `loadData` on this tab.
    as_of_input,
    /// Clear the active as-of date and return to the live view.
    /// No-op when no as-of date is set. Bound to Esc.
    clear_as_of,
    /// Toggle the convergence sub-view: spreadsheet-projected
    /// retirement date over time. Reads `imported_values.srf`.
    /// When active, replaces the main bands chart.
    toggle_convergence,
    /// Toggle the return-backtest sub-view: spreadsheet expected
    /// return vs realized 1y/3y/5y forward CAGR. Reads
    /// `imported_values.srf`. When active, replaces the main bands
    /// chart.
    toggle_return_backtest,
    /// Toggle auto-zoom on the actuals overlay. When the overlay is
    /// active and zoom is on (default), the chart's x-axis is
    /// clamped to roughly `[as_of, today + N years]` where N is the
    /// actuals span — without that clamp, a 10-year actuals line is
    /// squashed into the first 20% of a 50-year horizon. Pressing
    /// `z` flips back to the full horizon. No-op (with status hint)
    /// when the overlay is off.
    toggle_overlay_zoom,
};

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

pub const State = struct {
    /// Whether `activate` has populated `ctx` (or set up
    /// disabled state). Distinct from `ctx != null` because failed
    /// loads still mark loaded.
    loaded: bool = false,
    /// User-tunable inputs to the projection engine (annual
    /// contribution, target spending, retirement target percentile,
    /// etc.). Driven by annotations on the portfolio file.
    config: @import("../analytics/projections.zig").UserConfig = .{},
    /// Loaded projection context: bands, withdrawal tables,
    /// horizon configs, optional overlay actuals. Owned by State;
    /// freed via `freeLoaded`.
    ctx: ?@import("../views/projections.zig").ProjectionContext = null,
    /// Currently-focused horizon row in the terminal-value table.
    /// (Reserved for future expansion; not consumed today.)
    horizon_idx: usize = 0,
    /// Kitty graphics image id for the percentile-band chart, when
    /// using the Kitty path. Null when no image is currently
    /// transmitted to the terminal.
    image_id: ?u32 = null,
    /// Pixel dimensions of the most-recently-transmitted image
    /// (used to detect resize and re-render).
    image_width: u16 = 0,
    image_height: u16 = 0,
    /// True when the chart needs re-rendering on the next draw.
    /// Set by `freeLoaded`, by toggle_chart, by overlay_actuals
    /// changes, etc.
    chart_dirty: bool = true,
    /// Whether the percentile-band chart is shown. Toggled by
    /// `toggle_chart`. When false, the tab renders text-only with
    /// scroll (the full report).
    chart_visible: bool = true,
    /// Whether simulated lifecycle events (RMDs, lump-sum
    /// withdrawals) are included in the projection. Toggled by
    /// `toggle_events`; flipping forces a reload.
    events_enabled: bool = true,
    /// Y-axis bounds last used for the chart — informational, not
    /// load-bearing in dispatch.
    value_min: f64 = 0,
    value_max: f64 = 0,
    /// When non-null, the projections tab renders against a historical
    /// snapshot instead of the live portfolio. Set via the `d` popup
    /// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
    /// earlier available snapshot. Cleared by Esc on the projections
    /// tab when set, or by committing an empty / "live" input.
    as_of: ?zfin.Date = null,
    /// When auto-snap kicked in, `as_of` is the resolved snapshot
    /// date but `as_of_requested` remembers what the user actually
    /// typed — surfaced in the tab header as a muted "(requested X;
    /// snapped to Y, N days earlier)" note.
    as_of_requested: ?zfin.Date = null,
    /// When true, the projections chart overlays the realized
    /// portfolio trajectory (snapshots + imported_values) on top of
    /// the percentile bands. Toggled by `overlay_actuals`. Only
    /// meaningful when `as_of` is set; the action flashes a status
    /// message and leaves this off otherwise.
    overlay_actuals: bool = false,
    /// When true (default), and the actuals overlay is active, the
    /// chart x-axis is zoomed to roughly `[as_of, today + N years]`
    /// where N is the actuals span. Without this, a 10-year actuals
    /// line gets squashed into the first 20% of a 50-year horizon
    /// chart and the comparison-against-bands story is hard to read.
    /// Toggled by `toggle_overlay_zoom` (`z`); a no-op (with status
    /// hint) when the overlay is off because there's nothing
    /// short-history to zoom into.
    zoom_overlay: bool = true,

    /// Active sub-view replacing the main bands chart. The default
    /// view (`.bands`) renders the standard percentile-band chart
    /// + projection report. `.convergence` and `.return_backtest`
    /// pull data from `imported_values.srf` and render
    /// forecast-evaluation charts via `tui/forecast_chart.zig`.
    /// Toggled by the `c` and `r` keybinds; toggling either
    /// clears the other (mutually exclusive).
    sub_view: SubView = .bands,

    /// Cached convergence-chart points, populated lazily on first
    /// activation of `.convergence`. Owned by State; freed via
    /// `freeLoaded`.
    convergence_points: ?[]forecast.ConvergencePoint = null,
    /// Cached back-test anchors (one per anchor row, with three
    /// horizons pivoted into a single record). Populated lazily on
    /// first activation of `.return_backtest`. Owned by State;
    /// freed via `freeLoaded`.
    backtest_anchors: ?[]forecast_chart.BacktestAnchor = null,

    /// Tab-internal modal sub-state. The framework treats the
    /// tab as normal; projections' own `handleKey` /
    /// `statusOverride` hooks branch on this and route input
    /// to the modal handler. App.Mode does NOT carry the
    /// `date_input` variant.
    modal: Modal = .none,
};

/// Active chart sub-view on the projections tab. Mutually
/// exclusive — only one view replaces the main bands chart at a
/// time.
pub const SubView = enum {
    /// Default percentile-band chart + projection report.
    bands,
    /// Forecast convergence: spreadsheet-projected retirement
    /// date over time. Sources `imported_values.srf`.
    convergence,
    /// Return back-test: spreadsheet expected return vs realized
    /// 1y/3y/5y forward CAGR. Sources `imported_values.srf`.
    return_backtest,
};

/// Tab-internal modal sub-state. Today only one modal: the
/// as-of date input prompt (`d` keybind). Add variants here
/// if/when projections grows more modals.
pub const Modal = enum {
    /// No modal active.
    none,
    /// Date-input prompt is open. Reads from App's shared
    /// `input_buf` / `input_len`; commits via
    /// `cli.parseAsOfDate`. Same scaffolding as `symbol_input`.
    date_input,
};

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

pub const meta: framework.TabMeta(Action) = .{
    .label = "Projections",
    .default_bindings = &.{
        .{ .action = .overlay_actuals, .key = .{ .codepoint = 'o' } },
        .{ .action = .toggle_chart, .key = .{ .codepoint = 'v' } },
        .{ .action = .toggle_events, .key = .{ .codepoint = 'e' } },
        .{ .action = .as_of_input, .key = .{ .codepoint = 'd' } },
        .{ .action = .clear_as_of, .key = .{ .codepoint = vaxis.Key.escape } },
        .{ .action = .toggle_convergence, .key = .{ .codepoint = 'c' } },
        .{ .action = .toggle_return_backtest, .key = .{ .codepoint = 'b' } },
        .{ .action = .toggle_overlay_zoom, .key = .{ .codepoint = 'z' } },
    },
    .action_labels = std.enums.EnumArray(Action, []const u8).init(.{
        .overlay_actuals = "Toggle actuals overlay",
        .toggle_chart = "Toggle chart visibility",
        .toggle_events = "Toggle lifecycle events",
        .as_of_input = "Set as-of date",
        .clear_as_of = "Clear as-of date",
        .toggle_convergence = "Toggle convergence sub-view",
        .toggle_return_backtest = "Toggle return back-test sub-view",
        .toggle_overlay_zoom = "Toggle overlay zoom",
    }),
    .status_hints = &.{
        .toggle_chart,
        .toggle_events,
        .as_of_input,
        .toggle_convergence,
        .toggle_return_backtest,
    },
};

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 {
        freeLoaded(state, app);
        state.* = .{};
    }

    pub fn activate(state: *State, app: *App) !void {
        if (state.loaded) return;
        // Projections reads `app.portfolio.summary` and `.file`,
        // both populated synchronously by pd.load at App init.
        // No further data fetching needed here.
        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);
    }

    pub const tick = framework.noopTick(State);

    /// Pre-empt key handler. When the date-input modal is open
    /// (`state.modal == .date_input`), every key goes through
    /// here — global keymap matching is bypassed so typing `r`
    /// during input doesn't fire the refresh action. Returns
    /// `false` when no modal is active so dispatch falls through
    /// to the normal global → tab-local path.
    pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool {
        return switch (state.modal) {
            .none => false,
            .date_input => handleDateInputKey(state, app, key),
        };
    }

    /// Status-bar override. The date-input modal renders an
    /// interactive prompt with the live input buffer + cursor;
    /// otherwise the App-level default status applies.
    pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
        _ = app;
        return switch (state.modal) {
            .none => null,
            .date_input => .{ .input_prompt = .{
                .prompt = "As-of: ",
                .hint = " YYYY-MM-DD | 1M | live  Enter=confirm ",
            } },
        };
    }

    pub fn handleAction(state: *State, app: *App, action: Action) void {
        switch (action) {
            .overlay_actuals => {
                if (state.as_of == null) {
                    var arena_state = std.heap.ArenaAllocator.init(app.allocator);
                    defer arena_state.deinit();
                    const arena = arena_state.allocator();
                    const d_keys = app.keysForTabAction(arena, "projections", "as_of_input") catch return;
                    var buf: [128]u8 = undefined;
                    const msg = formatOverlayUnavailable(&buf, d_keys[0]) catch return;
                    app.setStatus(msg);
                    return;
                }
                state.overlay_actuals = !state.overlay_actuals;
                // Re-run loadData so the overlay section gets built
                // (or freed). The timeline load is the expensive
                // bit but it's rare — humans toggle this maybe a
                // few times per session.
                freeLoaded(state, app);
                state.loaded = false;
                loadData(state, app);
                state.chart_dirty = true;
                if (state.overlay_actuals) {
                    app.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
                } else {
                    app.setStatus("Overlay: OFF");
                }
            },
            .toggle_chart => {
                state.chart_visible = !state.chart_visible;
                state.chart_dirty = true;
                app.scroll_offset = 0;
            },
            .toggle_events => {
                state.events_enabled = !state.events_enabled;
                freeLoaded(state, app);
                state.loaded = false;
                loadData(state, app);
                const status_msg = if (state.events_enabled) "Events enabled" else "Events disabled";
                app.setStatus(status_msg);
            },
            .as_of_input => {
                state.modal = .date_input;
                app.input_len = 0;
                // No setStatus — `statusOverride` returns the
                // input prompt while `state.modal == .date_input`.
            },
            .clear_as_of => {
                // No-op when no as-of date is set. Returns to the
                // live view by clearing as-of state and reloading.
                if (state.as_of == null) return;
                state.as_of = null;
                state.as_of_requested = null;
                state.overlay_actuals = false;
                tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});
                app.setStatus("As-of cleared — showing live");
            },
            .toggle_convergence => {
                if (state.sub_view == .convergence) {
                    // Toggle off — return to default bands view.
                    // Clear the status override so the default
                    // contextual help (status_hints) reappears.
                    state.sub_view = .bands;
                    app.status_len = 0;
                } else {
                    state.sub_view = .convergence;
                    ensureConvergenceLoaded(state, app);
                    app.setStatus("Sub-view: convergence — model's directional honesty, not SWR validity");
                }
                state.chart_dirty = true;
                app.scroll_offset = 0;
            },
            .toggle_return_backtest => {
                if (state.sub_view == .return_backtest) {
                    state.sub_view = .bands;
                    app.status_len = 0;
                } else {
                    state.sub_view = .return_backtest;
                    ensureBacktestLoaded(state, app);
                    app.setStatus("Sub-view: return back-test — model's expected-return honesty, not SWR validity");
                }
                state.chart_dirty = true;
                app.scroll_offset = 0;
            },
            .toggle_overlay_zoom => {
                // No-op when the overlay isn't active. Without an
                // overlay there's no short-history actuals line to
                // give visual priority to, so zoom would just
                // truncate the band envelope for no reason.
                if (!state.overlay_actuals) {
                    app.setStatus("Zoom only applies when the actuals overlay is on");
                    return;
                }
                state.zoom_overlay = !state.zoom_overlay;
                state.chart_dirty = true;
                if (state.zoom_overlay) {
                    app.setStatus("Zoom: ON — x-axis clamped to overlay span");
                } else {
                    app.setStatus("Zoom: OFF — full horizon");
                }
            },
        }
    }

    /// Projections requires a loaded portfolio (the simulation
    /// engine reads lots / allocations from `app.portfolio`). Same
    /// predicate as analysis_tab and history_tab.
    pub fn isDisabled(app: *App) bool {
        return app.portfolio.file == null;
    }

    /// Drop cached projection data on portfolio reload. The
    /// projection result holds pointers into the previous
    /// portfolio's memory; invalidate before the underlying data
    /// is freed.
    ///
    /// 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 when the
    /// arena resets, producing use-after-free crashes on the
    /// next draw. The orchestrator
    /// (portfolio_tab.reloadPortfolioFile) calls
    /// `app.loadTabData()` AFTER pd.reload completes, which
    /// re-activates the active tab against fresh data.
    pub fn onPortfolioReload(state: *State, app: *App) void {
        freeLoaded(state, app);
        state.loaded = false;
    }
};

/// Format the "overlay unavailable" status hint shown when the user
/// presses the overlay-toggle key while no as-of date is set. Pure
/// function over the as-of-input key string.
pub fn formatOverlayUnavailable(buf: []u8, as_of_input_key: []const u8) std.fmt.BufPrintError![]const u8 {
    return std.fmt.bufPrint(buf, "Overlay only available with --as-of (press {s} to set)", .{as_of_input_key});
}

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

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

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

    const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
    const portfolio_dir = portfolio_path[0..dir_end];

    // As-of mode — load historical snapshot + ctx. This path is
    // independent of `app.portfolio.summary` / `app.portfolio` because
    // the snapshot's own totals and lot composition are the source of
    // truth for the projection.
    //
    // On any failure (no snapshot at/before requested date, unreadable
    // file, compute error) we clear the as-of state, leave a status
    // message explaining why, and fall through to the live path so
    // the tab still shows something rather than going blank.
    as_of: {
        const requested_date = state.as_of orelse break :as_of;

        const resolution = resolveAsOf(state, app, portfolio_path, requested_date) orelse {
            // `setStatus` already called by resolveAsOf.
            state.as_of = null;
            state.as_of_requested = null;
            break :as_of;
        };
        const actual_date = resolution.actual;
        state.as_of = actual_date;
        // Preserve requested for the header note; clear if it matches actual.
        if (actual_date.eql(requested_date)) {
            state.as_of_requested = null;
        }

        const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch {
            app.setStatus("Failed to derive history dir — showing live");
            state.as_of = null;
            state.as_of_requested = null;
            break :as_of;
        };
        defer app.allocator.free(hist_dir);

        const ctx = switch (resolution.source) {
            .snapshot => snap: {
                var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
                    app.setStatus("Failed to load snapshot — showing live");
                    state.as_of = null;
                    state.as_of_requested = null;
                    break :as_of;
                };
                defer loaded.deinit(app.allocator);

                break :snap view.loadProjectionContextAsOf(
                    app.io,
                    app.allocator,
                    portfolio_dir,
                    &loaded.snap,
                    actual_date,
                    app.svc,
                    state.events_enabled,
                ) catch {
                    app.setStatus("Failed to compute as-of projections — showing live");
                    state.as_of = null;
                    state.as_of_requested = null;
                    break :as_of;
                };
            },
            .imported => imp: {
                // Imported-only as-of: scale today's allocations to
                // the imported liquid total. Requires the live
                // portfolio summary, which the portfolio tab loads
                // up-front into `app.portfolio.summary`.
                const summary = app.portfolio.summary orelse {
                    app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
                    state.as_of = null;
                    state.as_of_requested = null;
                    break :as_of;
                };
                const portfolio = app.portfolio.file orelse {
                    app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
                    state.as_of = null;
                    state.as_of_requested = null;
                    break :as_of;
                };

                break :imp view.loadProjectionContextFromImported(
                    app.io,
                    app.allocator,
                    portfolio_dir,
                    summary.allocations,
                    summary.total_value,
                    portfolio.totalCash(app.today),
                    portfolio.totalCdFaceValue(app.today),
                    resolution.liquid,
                    actual_date,
                    app.svc,
                    state.events_enabled,
                ) catch {
                    app.setStatus("Failed to compute as-of projections — showing live");
                    state.as_of = null;
                    state.as_of_requested = null;
                    break :as_of;
                };
            },
        };

        var ctx_with_overlay = ctx;
        // Attach the actuals overlay if the toggle is on. Failures
        // here are non-fatal — the chart still renders without the
        // overlay; the toggle stays on so the user knows the intent.
        if (state.overlay_actuals) {
            if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| {
                ctx_with_overlay.overlay_actuals = ov;
            } else |_| {
                // Silent — the chart-render path will simply not
                // draw an overlay layer. Status would be noisy on
                // every redraw.
            }
        }

        state.ctx = ctx_with_overlay;
        return;
    }

    // Live path. Reached either because no as-of was requested OR the
    // as-of branch above bailed and fell through after clearing state.
    const summary = app.portfolio.summary orelse {
        app.setStatus("No portfolio summary — visit Portfolio tab first");
        return;
    };

    const portfolio = app.portfolio.file orelse return;

    const ctx = view.loadProjectionContext(
        app.io,
        app.allocator,
        portfolio_dir,
        summary.allocations,
        summary.total_value,
        portfolio.totalCash(app.today),
        portfolio.totalCdFaceValue(app.today),
        app.svc,
        state.events_enabled,
        app.today,
    ) catch {
        app.setStatus("Failed to compute projections");
        return;
    };

    state.ctx = ctx;
}

/// Resolve the user's requested as-of date against the portfolio's
/// history directory, accepting either a native snapshot OR an
/// `imported_values.srf` row. Returns the resolved record, or null
/// with a status-bar message if no usable data exists.
///
/// Thin adapter over `history.resolveAsOfDate` — the shared pure
/// resolver owns exact-then-fallback logic; this wrapper maps its
/// errors to user-visible status-bar messages and handles the arena.
fn resolveAsOf(state: *State, app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
    var arena_state = std.heap.ArenaAllocator.init(app.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const hist_dir = history.deriveHistoryDir(arena, portfolio_path) catch {
        app.setStatus("Failed to derive history dir");
        return null;
    };

    const resolved = history.resolveAsOfDate(app.io, arena, hist_dir, requested) catch |err| switch (err) {
        error.NoDataAtOrBefore => {
            var status_buf: [128]u8 = undefined;
            const msg = std.fmt.bufPrint(&status_buf, "No snapshot or imported value at or before {f}", .{requested}) catch "No data at or before requested date";
            app.setStatus(msg);
            return null;
        },
        error.OutOfMemory => {
            app.setStatus("Out of memory resolving as-of");
            return null;
        },
        else => {
            app.setStatus("Error accessing as-of data");
            return null;
        },
    };

    if (!resolved.exact) {
        // Remember the original request for the muted header note.
        state.as_of_requested = requested;
    }
    return resolved;
}

/// Load the merged history timeline (snapshots + imported_values)
/// and produce an overlay section for the projections chart.
/// Allocates the resulting `OverlayActualsSection.points` slice
/// from `app.allocator` so it survives until `freeLoaded` runs.
fn loadOverlayActuals(app: *App, portfolio_path: []const u8, as_of: zfin.Date) !view.OverlayActualsSection {
    var loaded = try history.loadTimeline(app.io, app.allocator, portfolio_path);
    defer loaded.deinit();
    return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today);
}

pub fn freeLoaded(state: *State, app: *App) void {
    if (state.ctx) |*ctx| {
        app.allocator.free(ctx.data.withdrawals);
        for (ctx.data.bands) |b| {
            if (b) |slice| app.allocator.free(slice);
        }
        app.allocator.free(ctx.data.bands);
        if (ctx.earliest) |er| app.allocator.free(er);
        if (ctx.overlay_actuals) |*ov| ov.deinit();
    }
    state.ctx = null;
    // Sub-view caches are reset alongside the main projection
    // context. They reload lazily on next sub-view activation.
    if (state.convergence_points) |pts| app.allocator.free(pts);
    state.convergence_points = null;
    if (state.backtest_anchors) |an| app.allocator.free(an);
    state.backtest_anchors = null;
    // Mark projection chart as dirty so it re-renders on next draw
    state.chart_dirty = true;
}

/// Lazy-load the convergence points from `imported_values.srf`.
/// No-op when already loaded. Errors are surfaced to status —
/// the sub-view's own render will fall back to a "no data" line.
fn ensureConvergenceLoaded(state: *State, app: *App) void {
    if (state.convergence_points != null) return;
    const path = importedValuesPath(app) orelse {
        app.setStatus("imported_values.srf not found");
        return;
    };
    defer app.allocator.free(path);

    var iv = imported.loadImportedValues(app.io, app.allocator, path) catch {
        app.setStatus("Failed to load imported_values.srf");
        return;
    };
    defer iv.deinit();

    state.convergence_points = forecast.convergencePoints(app.allocator, iv.points) catch null;
}

/// Lazy-load the back-test anchors from `imported_values.srf`,
/// running `forecast.returnBacktest` and pivoting (anchor,
/// horizon) rows into per-anchor records suitable for the chart
/// renderer. No-op when already loaded.
fn ensureBacktestLoaded(state: *State, app: *App) void {
    if (state.backtest_anchors != null) return;
    const path = importedValuesPath(app) orelse {
        app.setStatus("imported_values.srf not found");
        return;
    };
    defer app.allocator.free(path);

    var iv = imported.loadImportedValues(app.io, app.allocator, path) catch {
        app.setStatus("Failed to load imported_values.srf");
        return;
    };
    defer iv.deinit();

    // Build CPI list from Shiller annual data. Real-mode is OFF
    // by default in the TUI; the toggle is a future enhancement.
    var cpi_list: std.ArrayList(milestones.YearCpi) = .empty;
    defer cpi_list.deinit(app.allocator);
    for (shiller.annual_returns) |yr| {
        cpi_list.append(app.allocator, .{ .year = yr.year, .cpi = yr.cpi_inflation }) catch return;
    }

    const horizons = [_]u16{ 1, 3, 5 };
    const rows = forecast.returnBacktest(app.allocator, iv.points, &horizons, false, cpi_list.items) catch return;
    defer app.allocator.free(rows);

    // Pivot (anchor, horizon) rows into per-anchor records.
    var anchors: std.ArrayList(forecast_chart.BacktestAnchor) = .empty;
    errdefer anchors.deinit(app.allocator);

    var i: usize = 0;
    while (i < rows.len) {
        const anchor_date = rows[i].anchor_date;
        const expected = rows[i].expected_return;
        var r1: ?f64 = null;
        var r3: ?f64 = null;
        var r5: ?f64 = null;
        while (i < rows.len and rows[i].anchor_date.eql(anchor_date)) : (i += 1) {
            switch (rows[i].horizon_years) {
                1 => r1 = rows[i].realized_cagr,
                3 => r3 = rows[i].realized_cagr,
                5 => r5 = rows[i].realized_cagr,
                else => {},
            }
        }
        anchors.append(app.allocator, .{
            .anchor_date = anchor_date,
            .expected = expected,
            .realized_1y = r1,
            .realized_3y = r3,
            .realized_5y = r5,
        }) catch return;
    }

    state.backtest_anchors = anchors.toOwnedSlice(app.allocator) catch null;
}

/// Resolve the path to `<portfolio_dir>/history/imported_values.srf`
/// for the current portfolio, returning null when no portfolio is
/// loaded. Caller owns the returned slice.
fn importedValuesPath(app: *App) ?[]u8 {
    const ppath = app.anchorPath() orelse return null;
    const hist_dir = history.deriveHistoryDir(app.allocator, ppath) catch return null;
    defer app.allocator.free(hist_dir);
    return std.fs.path.join(app.allocator, &.{ hist_dir, "imported_values.srf" }) catch null;
}

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

pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {

    // Determine whether to use Kitty graphics
    const use_kitty = switch (app.chart_config.mode) {
        .braille => false,
        .kitty => true,
        .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
    };

    // Sub-view dispatch: convergence and return-backtest replace
    // the main bands chart entirely. They have their own
    // chart-mode (Kitty graphics) and text-mode (line-list)
    // renderers; pick by the same `use_kitty` heuristic.
    switch (state.sub_view) {
        .bands => {}, // fall through to default rendering below
        .convergence => {
            if (use_kitty) {
                drawConvergenceWithKitty(state, app, arena, buf, width, height) catch {
                    try drawConvergenceWithScroll(state, app, arena, buf, width, height);
                };
            } else {
                try drawConvergenceWithScroll(state, app, arena, buf, width, height);
            }
            return;
        },
        .return_backtest => {
            if (use_kitty) {
                drawBacktestWithKitty(state, app, arena, buf, width, height) catch {
                    try drawBacktestWithScroll(state, app, arena, buf, width, height);
                };
            } else {
                try drawBacktestWithScroll(state, app, arena, buf, width, height);
            }
            return;
        },
    }

    // Need bands data for the chart
    const has_bands = if (state.ctx) |pctx| blk: {
        const horizons = pctx.config.getHorizons();
        if (horizons.len == 0) break :blk false;
        const last_idx = horizons.len - 1;
        if (pctx.data.bands[last_idx]) |bands| {
            break :blk bands.len >= 2;
        }
        break :blk false;
    } else false;

    if (use_kitty and has_bands and state.chart_visible) {
        drawWithKittyChart(state, app, arena, buf, width, height) catch {
            try drawWithScroll(state, app, arena, buf, width, height);
        };
    } else {
        try drawWithScroll(state, app, arena, buf, width, height);
    }
}

/// Render styled lines with scroll_offset applied.
fn drawWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const all_lines = try buildLines(state, app, arena);
    const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0);
    try app.drawStyledContent(arena, buf, width, height, all_lines[start..]);
}

/// Draw projections tab using Kitty graphics protocol for the percentile band chart.
fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const th = app.theme;
    const pctx = state.ctx orelse return;
    const config = pctx.config;
    const horizons = config.getHorizons();
    const last_idx = horizons.len - 1;
    const full_bands = pctx.data.bands[last_idx] orelse return;
    if (full_bands.len < 2) return;

    // Effective bands slice for both the chart render AND the
    // right-edge axis labels. When the overlay is on and zoom is
    // enabled, the chart is clamped to roughly `[year 0, year
    // 2 * today_years]` so a short actuals history isn't squashed
    // into the start of a 50-year horizon. The label code below
    // reads `bands[bands.len - 1]` to position p10/p50/p90 etc.
    // against the rendered y-range — those two views MUST agree
    // on which slice was rendered, otherwise the label `val` is
    // outside the chart's `[value_min, value_max]` window and the
    // resulting `row_f` underflows when cast to usize.
    const overlay_today_years: ?f64 = blk: {
        if (!state.overlay_actuals) break :blk null;
        const ctx_data = state.ctx orelse break :blk null;
        const ov = ctx_data.overlay_actuals orelse break :blk null;
        break :blk ov.today_years;
    };
    const bands = if (state.zoom_overlay) bz: {
        const ty = overlay_today_years orelse break :bz full_bands;
        const window_years_f = ty * 2.0;
        if (window_years_f <= 0 or !std.math.isFinite(window_years_f)) break :bz full_bands;
        const window_years: usize = @intFromFloat(@ceil(window_years_f));
        const want = window_years + 1; // inclusive of year 0 and year `window_years`
        if (want >= full_bands.len) break :bz full_bands;
        if (want < 2) break :bz full_bands;
        break :bz full_bands[0..want];
    } else full_bands;

    // Build text header (benchmark comparison + allocation note)
    var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try buildHeaderSection(state, app, arena, &header_lines, pctx);

    // Chart title
    try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try header_lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  Portfolio Projection ({d}-Year, percentile bands at 99% withdrawal)", .{horizons[last_idx]}),
        .style = th.headerStyle(),
    });
    try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Pre-build footer to compute its line count for adaptive chart sizing
    var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try buildFooterSection(app, arena, &footer_lines, pctx);
    const footer_line_count: u16 = @intCast(@min(footer_lines.items.len, height));

    // Draw header into buffer
    const header_slice = try header_lines.toOwnedSlice(arena);
    try app.drawStyledContent(arena, buf, width, height, header_slice);

    // Calculate chart area — adaptive: leave room for footer + 1 row for year axis
    const header_rows: u16 = @intCast(@min(header_slice.len, height));
    const footer_reserve = footer_line_count + 1; // +1 for year axis row
    const chart_rows = height -| header_rows -| footer_reserve;
    if (chart_rows < 6) {
        // Not enough space for chart — fall back to text-only with scroll
        try drawWithScroll(state, app, arena, buf, width, height);
        return;
    }

    // Compute pixel dimensions
    const cell_size = app.cellPixelSize();
    const cell_w: u32 = cell_size.width;
    const cell_h: u32 = cell_size.height;
    const label_cols: u16 = 12; // columns for axis labels on the right
    const chart_cols = width -| 2 -| label_cols;
    if (chart_cols == 0) return;
    const px_w: u32 = @as(u32, chart_cols) * cell_w;
    const px_h: u32 = @as(u32, chart_rows) * cell_h;

    if (px_w < 100 or px_h < 100) return;
    const capped_w = @min(px_w, app.chart_config.max_width);
    const capped_h = @min(px_h, app.chart_config.max_height);

    // Render or reuse cached image
    if (state.chart_dirty) {
        // Free old image
        if (state.image_id) |old_id| {
            if (app.vx_app) |va| {
                va.vx.freeImage(va.tty.writer(), old_id);
            }
            state.image_id = null;
        }

        if (app.vx_app) |va| {
            // Build the actuals overlay only when overlay is on AND
            // we're in as-of mode. The overlay is meaningless without
            // an as-of anchor (no projected future to overlay onto).
            //
            // Copy the view's ActualsPoint slice into the chart's
            // ActualsPoint slice — same field shape, but distinct
            // types so the chart module stays leaf-level (no view
            // dependency). Render-scoped allocation; fine to do per
            // dirty redraw because the overlay is at most ~12 years
            // of weekly data (~600 points).
            const overlay_input: ?projection_chart.ActualsOverlay = blk: {
                if (!state.overlay_actuals) break :blk null;
                const ctx_data = state.ctx orelse break :blk null;
                const ov = ctx_data.overlay_actuals orelse break :blk null;
                const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
                for (ov.points, 0..) |p, idx| {
                    ov_buf[idx] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
                }
                break :blk .{ .points = ov_buf, .today_years = ov.today_years };
            };
            defer if (overlay_input) |ov| app.allocator.free(@constCast(ov.points));

            const chart_result = projection_chart.renderProjectionChart(
                app.io,
                app.allocator,
                bands,
                capped_w,
                capped_h,
                th,
                overlay_input,
            ) catch {
                state.chart_dirty = false;
                return;
            };
            defer app.allocator.free(chart_result.rgb_data);

            // Base64-encode and transmit
            const base64_enc = std.base64.standard.Encoder;
            const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
                state.chart_dirty = false;
                return;
            };
            defer app.allocator.free(b64_buf);
            const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);

            const img = va.vx.transmitPreEncodedImage(
                va.tty.writer(),
                encoded,
                chart_result.width,
                chart_result.height,
                .rgb,
            ) catch {
                state.chart_dirty = false;
                return;
            };

            state.image_id = img.id;
            state.image_width = @intCast(chart_cols);
            state.image_height = chart_rows;
            state.value_min = chart_result.value_min;
            state.value_max = chart_result.value_max;
            state.chart_dirty = false;
        }
    }

    // Place the image in the cell buffer
    if (state.image_id) |img_id| {
        const chart_row_start: usize = header_rows;
        const chart_col_start: usize = 1;
        const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
        if (buf_idx < buf.len) {
            buf[buf_idx] = .{
                .char = .{ .grapheme = " " },
                .style = th.contentStyle(),
                .image = .{
                    .img_id = img_id,
                    .options = .{
                        .size = .{
                            .rows = state.image_height,
                            .cols = state.image_width,
                        },
                        .scale = .contain,
                    },
                },
            };
        }

        // Axis labels (dollar values on the right side)
        const img_rows = state.image_height;
        const label_col: usize = @as(usize, chart_col_start) + @as(usize, state.image_width) + 1;
        const label_style = th.mutedStyle();

        if (label_col + 10 <= width and img_rows >= 4 and state.value_max > state.value_min) {
            // Label band boundaries at the right edge, in priority order:
            // p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority).
            const last_band = bands[bands.len - 1];
            const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
            const val_range = state.value_max - state.value_min;
            const rows_f: f64 = @floatFromInt(img_rows -| 1);

            var placed_rows: [5]usize = undefined;
            var placed_count: usize = 0;

            for (label_values) |val| {
                const norm = (val - state.value_min) / val_range;
                const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f;
                // Defensive: a label value outside [value_min, value_max]
                // produces a row_f outside the chart strip, which would
                // panic on @intFromFloat for usize. Skip silently — the
                // label just doesn't render, which is the right thing
                // when the band value is off-chart. (This used to fire
                // when the bands slice and the chart-render slice
                // disagreed; both now read from the same `bands` local,
                // but the guard is cheap insurance.)
                if (!std.math.isFinite(row_f)) continue;
                if (row_f < 0) continue;
                const row: usize = @intFromFloat(@round(row_f));
                if (row >= height) continue;

                // Skip if this label would overlap any already-placed label
                var overlaps = false;
                for (placed_rows[0..placed_count]) |prev_row| {
                    const diff = if (row >= prev_row) row - prev_row else prev_row - row;
                    if (diff <= 1) {
                        overlaps = true;
                        break;
                    }
                }
                if (overlaps) continue;

                // Format as whole dollars (no decimals)
                var lbl_buf: [16]u8 = undefined;
                const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(val).whole()}) catch "$?";

                const start_idx = row * @as(usize, width) + label_col;
                for (lbl, 0..) |ch, ci| {
                    const idx = start_idx + ci;
                    if (idx < buf.len and label_col + ci < width) {
                        buf[idx] = .{
                            .char = .{ .grapheme = tui.glyph(ch) },
                            .style = label_style,
                        };
                    }
                }
                placed_rows[placed_count] = row;
                placed_count += 1;
            }

            // Year axis: "Now" on left edge, "{horizon}yr" on right edge of chart
            const axis_row: usize = chart_row_start + @as(usize, img_rows);
            if (axis_row < height) {
                const axis_base = axis_row * @as(usize, width);
                // "Now" at left
                const now_label = "Now";
                for (now_label, 0..) |ch, ci| {
                    const idx = axis_base + chart_col_start + ci;
                    if (idx < buf.len) {
                        buf[idx] = .{
                            .char = .{ .grapheme = tui.glyph(ch) },
                            .style = label_style,
                        };
                    }
                }
                // "{horizon}yr" at right edge of chart area
                var yr_buf: [8]u8 = undefined;
                const yr_label = std.fmt.bufPrint(&yr_buf, "{d}yr", .{horizons[last_idx]}) catch "??yr";
                const yr_start = chart_col_start + @as(usize, chart_cols) -| yr_label.len;
                for (yr_label, 0..) |ch, ci| {
                    const idx = axis_base + yr_start + ci;
                    if (idx < buf.len) {
                        buf[idx] = .{
                            .char = .{ .grapheme = tui.glyph(ch) },
                            .style = label_style,
                        };
                    }
                }
            }
        }

        // Render footer (terminal values + withdrawal table) below the chart
        const footer_start_row = header_rows + state.image_height + 1; // +1 for axis row
        if (footer_start_row + 4 < height) {
            const footer_slice = try footer_lines.toOwnedSlice(arena);
            const footer_buf_start = footer_start_row * @as(usize, width);
            const remaining_height = height - @as(u16, @intCast(footer_start_row));
            if (footer_buf_start < buf.len) {
                try app.drawStyledContent(arena, buf[footer_buf_start..], width, remaining_height, footer_slice);
            }
        }
    }
}

/// Build the header section (benchmark comparison table + allocation note).
fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
    const th = app.theme;
    const comparison = pctx.comparison;
    const config = pctx.config;
    const stock_pct = pctx.stock_pct;

    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Benchmark Comparison (price-only weighted return)", .style = th.headerStyle() });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Column headers
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{
            "", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
        }),
        .style = th.headerStyle(),
    });

    // Return rows
    var spy_bufs: [5][16]u8 = undefined;
    var spy_label_buf: [32]u8 = undefined;
    const spy_row = view.buildReturnRow(
        view.fmtBenchmarkLabel(&spy_label_buf, config.benchmark_stock, stock_pct * 100),
        comparison.stock_returns,
        &spy_bufs,
        false,
    );
    try appendReturnRow(lines, arena, th, spy_row);

    var agg_bufs: [5][16]u8 = undefined;
    var agg_label_buf: [32]u8 = undefined;
    const agg_row = view.buildReturnRow(
        view.fmtBenchmarkLabel(&agg_label_buf, config.benchmark_bond, pctx.bond_pct * 100),
        comparison.bond_returns,
        &agg_bufs,
        false,
    );
    try appendReturnRow(lines, arena, th, agg_row);

    var bench_bufs: [5][16]u8 = undefined;
    const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
    try appendReturnRow(lines, arena, th, bench_row);

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

    var port_bufs: [5][16]u8 = undefined;
    const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);
    try appendReturnRow(lines, arena, th, port_row);

    // Projected return (conservative estimate from benchmark analytics)
    {
        var buf: [16]u8 = undefined;
        const cell = view.fmtReturnCell(&buf, comparison.conservative_return);
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s: <30}{s: >8}", .{ "Projected return", cell.text }),
            .style = th.mutedStyle(),
        });
    }

    // Target allocation note
    {
        var note_buf: [128]u8 = undefined;
        if (view.fmtAllocationNote(&note_buf, config.target_stock_pct, stock_pct)) |note| {
            try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
            try lines.append(arena, .{
                .text = try std.fmt.allocPrint(arena, "  {s}", .{note.text}),
                .style = th.styleFor(note.style),
            });
        }
    }

    // Accumulation phase / Earliest retirement blocks. Use the
    // historical snapshot date when one is configured so the
    // promoted date and earliest-retirement grid anchor on the
    // same reference point as the rest of the as-of-mode display.
    const ref_date = state.as_of orelse app.today;
    try appendAccumulationBlocks(lines, arena, th, pctx, ref_date);
}

/// Build the footer section (terminal values + safe withdrawal table).
fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
    const th = app.theme;
    const config = pctx.config;
    const horizons = config.getHorizons();

    // Terminal portfolio value
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = "  Terminal Portfolio Value (nominal, at 99% withdrawal rate)",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
        .style = th.headerStyle(),
    });

    {
        const all_bands = pctx.data.bands;
        const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
        const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
        for (p_labels, p_styles, 0..) |plabel, pstyle, pi| {
            const row = try view.buildPercentileRow(arena, plabel, pi, all_bands, pstyle);
            try lines.append(arena, .{
                .text = try std.fmt.allocPrint(arena, "  {s}", .{row.text}),
                .style = th.styleFor(row.style),
            });
        }
    }

    // Safe withdrawal table
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = "  Safe Withdrawal (FIRECalc historical simulation)",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}),
        .style = th.headerStyle(),
    });

    const cached_wr = pctx.data.withdrawals;
    const confidence_levels = config.getConfidenceLevels();
    for (confidence_levels, 0..) |conf, ci| {
        const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci);
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s}", .{rows.amount.text}),
            .style = th.contentStyle(),
        });
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s}", .{rows.rate.text}),
            .style = th.mutedStyle(),
        });
    }

    // Life events summary
    try appendEventSummary(lines, app.today, arena, th, pctx);
}

fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Date, arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {
    const events = pctx.config.getEvents();
    if (events.len == 0) return;
    const ages = pctx.config.currentAges(as_of);
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Life Events", .style = th.headerStyle() });
    for (events) |*ev| {
        const line = try view.fmtEventLine(arena, ev, &ages);
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s}", .{line.text}),
            .style = th.styleFor(line.style),
        });
    }
}

/// Append the "Accumulation phase" + "Earliest retirement" blocks
/// (driven by the user's target retirement date and target spending
/// inputs) to a styled-lines list. Always emits the retirement
/// line; the contribution row is suppressed when both contribution
/// and accumulation are zero. Earliest-retirement grid only renders
/// when `target_spending` is configured.
fn appendAccumulationBlocks(
    lines: *std.ArrayListUnmanaged(StyledLine),
    arena: std.mem.Allocator,
    th: theme.Theme,
    pctx: view.ProjectionContext,
    as_of: zfin.Date,
) !void {
    // Accumulation phase block.
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{ .text = "  Accumulation phase:", .style = th.headerStyle() });

    var line_buf: [128]u8 = undefined;
    const parts = view.splitRetirementLine(&line_buf, pctx.retirement, &pctx.config);
    if (parts.value_style == .negative) {
        // Per-cell styled line: 4-space indent + neutral label +
        // red value. Other forms (none/at_date/at_age/promoted) all
        // render with the value style matching the label, so the
        // single-style fast path below is fine for them.
        const indent = "    ";
        const total_len = indent.len + parts.label_text.len + parts.value_text.len;
        const graphemes = try arena.alloc([]const u8, total_len);
        const cell_styles = try arena.alloc(vaxis.Style, total_len);
        const neutral = th.contentStyle();
        const negative = th.styleFor(.negative);
        var gp: usize = 0;
        for (indent) |ch| {
            graphemes[gp] = tui.glyph(ch);
            cell_styles[gp] = neutral;
            gp += 1;
        }
        for (parts.label_text) |ch| {
            graphemes[gp] = tui.glyph(ch);
            cell_styles[gp] = neutral;
            gp += 1;
        }
        for (parts.value_text) |ch| {
            graphemes[gp] = tui.glyph(ch);
            cell_styles[gp] = negative;
            gp += 1;
        }
        try lines.append(arena, .{
            .text = "",
            .style = neutral,
            .graphemes = graphemes[0..gp],
            .cell_styles = cell_styles[0..gp],
        });
    } else {
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "    {s}{s}", .{ parts.label_text, parts.value_text }),
            .style = th.contentStyle(),
        });
    }

    if (try view.fmtContributionLine(arena, pctx.config.annual_contribution, pctx.config.contribution_inflation_adjusted, pctx.retirement.accumulation_years)) |contrib| {
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "    {s}", .{contrib}),
            .style = th.contentStyle(),
        });
    }

    if (pctx.accumulation) |acc| {
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "    Median portfolio at retirement:  {f}", .{Money.from(acc.median_at_retirement).trim()}),
            .style = th.contentStyle(),
        });
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "    Range (10th\u{2013}90th percentile):    {f} to {f}", .{
                Money.from(acc.p10_at_retirement).trim(),
                Money.from(acc.p90_at_retirement).trim(),
            }),
            .style = th.mutedStyle(),
        });
    }

    // Earliest retirement block (target-spending input).
    if (pctx.earliest) |earliest| {
        const target = pctx.config.target_spending orelse return;
        const adj: []const u8 = if (pctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal";

        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  Earliest retirement (target spending: {f}/yr {s})", .{ Money.from(target).trim(), adj }),
            .style = th.headerStyle(),
        });

        const horizons = pctx.config.getHorizons();
        const confs = pctx.config.getConfidenceLevels();
        const cell_width: usize = 14;
        const label_width: usize = 25;

        // Header row.
        {
            var hdr: std.ArrayListUnmanaged(u8) = .empty;
            try hdr.appendNTimes(arena, ' ', label_width);
            for (horizons) |h| {
                var hbuf: [16]u8 = undefined;
                const hlabel = view.fmtHorizonLabel(&hbuf, h);
                try hdr.appendNTimes(arena, ' ', cell_width -| hlabel.len);
                try hdr.appendSlice(arena, hlabel);
            }
            try lines.append(arena, .{
                .text = try std.fmt.allocPrint(arena, "  {s}", .{hdr.items}),
                .style = th.mutedStyle(),
            });
        }

        for (confs, 0..) |conf, ci| {
            const row = try view.buildEarliestRow(arena, conf, horizons, earliest, ci, as_of);

            // Per-cell styled row so individual "infeasible" cells
            // can render in `.negative` (red) while feasible date
            // cells render in the default content color. A single
            // `style` on the StyledLine would force every cell to
            // the same color and bury the bad-news cells.
            //
            // Layout: "  " + label + label-pad + (cell-pad + cell-text)*
            const indent = "  ";
            var total: usize = indent.len + row.label_text.len;
            const label_pad = if (label_width > row.label_text.len) label_width - row.label_text.len else 0;
            total += label_pad;
            for (row.cells) |cell| {
                const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0;
                total += cellpad + cell.text.len;
            }

            const graphemes = try arena.alloc([]const u8, total);
            const cell_styles = try arena.alloc(vaxis.Style, total);
            const neutral = th.contentStyle();
            var gp: usize = 0;

            for (indent) |ch| {
                graphemes[gp] = tui.glyph(ch);
                cell_styles[gp] = neutral;
                gp += 1;
            }
            for (row.label_text) |ch| {
                graphemes[gp] = tui.glyph(ch);
                cell_styles[gp] = neutral;
                gp += 1;
            }
            var pad_i: usize = 0;
            while (pad_i < label_pad) : (pad_i += 1) {
                graphemes[gp] = " ";
                cell_styles[gp] = neutral;
                gp += 1;
            }
            for (row.cells) |cell| {
                const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0;
                var cp: usize = 0;
                while (cp < cellpad) : (cp += 1) {
                    graphemes[gp] = " ";
                    cell_styles[gp] = neutral;
                    gp += 1;
                }
                const cell_style = th.styleFor(cell.style);
                for (cell.text) |ch| {
                    graphemes[gp] = tui.glyph(ch);
                    cell_styles[gp] = cell_style;
                    gp += 1;
                }
            }

            try lines.append(arena, .{
                .text = "",
                .style = neutral,
                .graphemes = graphemes[0..gp],
                .cell_styles = cell_styles[0..gp],
            });
        }
    }
}

// ── Sub-view renderers ────────────────────────────────────────

/// Convert renderer-agnostic `view.ForecastLine`s into the
/// TUI's `StyledLine` shape. Maps `intent` → theme style and
/// honors the `bold` flag (rendered as `headerStyle` —
/// purple+bold). Bridges the view-model and the TUI's draw
/// path so the convergence/back-test fallbacks share their
/// formatting with the CLI.
fn forecastLinesToStyled(
    arena: std.mem.Allocator,
    th: theme.Theme,
    lines: []const view.ForecastLine,
) ![]StyledLine {
    const out = try arena.alloc(StyledLine, lines.len);
    for (lines, 0..) |ln, i| {
        const style: vaxis.Style = if (ln.bold) th.headerStyle() else th.styleFor(ln.intent);
        out[i] = .{ .text = ln.text, .style = style };
    }
    return out;
}

/// Render the convergence sub-view as a Kitty-graphics chart.
/// Falls back to scroll-mode on render failure.
fn drawConvergenceWithKitty(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const th = app.theme;
    const points = state.convergence_points orelse return error.NoData;
    if (points.len < 2) return error.NoData;

    // Header: pulled from the shared view-model so the chart
    // path matches the CLI / scroll-fallback exactly. Prepend a
    // blank to give the title some breathing room.
    const view_header = try view.convergenceHeaderLines(arena, points);
    var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    const styled_header = try forecastLinesToStyled(arena, th, view_header);
    try header_lines.appendSlice(arena, styled_header);

    // Footer: keybind hints
    var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try footer_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try footer_lines.append(arena, .{
        .text = "  Press 'c' to return to bands view, 'b' for return back-test.",
        .style = th.mutedStyle(),
    });

    const header_slice = try header_lines.toOwnedSlice(arena);
    try app.drawStyledContent(arena, buf, width, height, header_slice);

    const header_rows: u16 = @intCast(@min(header_slice.len, height));
    const footer_reserve: u16 = @intCast(footer_lines.items.len);
    const chart_rows = height -| header_rows -| footer_reserve;
    if (chart_rows < 6) {
        try drawConvergenceWithScroll(state, app, arena, buf, width, height);
        return;
    }

    const cell_size = app.cellPixelSize();
    const px_w: u32 = @as(u32, width -| 2) * cell_size.width;
    const px_h: u32 = @as(u32, chart_rows) * cell_size.height;
    if (px_w < 100 or px_h < 100) return error.TooSmall;
    const capped_w = @min(px_w, app.chart_config.max_width);
    const capped_h = @min(px_h, app.chart_config.max_height);

    if (state.chart_dirty) {
        if (state.image_id) |old_id| {
            if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), old_id);
            state.image_id = null;
        }
        if (app.vx_app) |va| {
            const result = forecast_chart.renderConvergenceChart(app.io, app.allocator, points, capped_w, capped_h, th) catch {
                state.chart_dirty = false;
                return;
            };
            defer app.allocator.free(result.rgb_data);

            const base64_enc = std.base64.standard.Encoder;
            const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(result.rgb_data.len)) catch {
                state.chart_dirty = false;
                return;
            };
            defer app.allocator.free(b64_buf);
            const encoded = base64_enc.encode(b64_buf, result.rgb_data);

            const img = va.vx.transmitPreEncodedImage(va.tty.writer(), encoded, result.width, result.height, .rgb) catch {
                state.chart_dirty = false;
                return;
            };
            state.image_id = img.id;
            state.image_width = width -| 2;
            state.image_height = chart_rows;
            state.chart_dirty = false;
        }
    }

    if (state.image_id) |img_id| {
        const buf_idx = @as(usize, header_rows) * @as(usize, width) + 1;
        if (buf_idx < buf.len) {
            buf[buf_idx] = .{
                .char = .{ .grapheme = " " },
                .style = th.contentStyle(),
                .image = .{
                    .img_id = img_id,
                    .options = .{
                        .size = .{ .rows = state.image_height, .cols = state.image_width },
                        .scale = .contain,
                    },
                },
            };
        }
    }

    // Draw footer below the chart
    const footer_start_row: usize = @as(usize, header_rows) + @as(usize, chart_rows);
    if (footer_start_row < height) {
        const footer_buf_offset = footer_start_row * @as(usize, width);
        const remaining_buf_len = buf.len -| footer_buf_offset;
        const footer_height: u16 = @intCast(height -| footer_start_row);
        if (remaining_buf_len > 0) {
            try app.drawStyledContent(arena, buf[footer_buf_offset..], width, footer_height, footer_lines.items);
        }
    }
}

/// Render the convergence sub-view as scrollable styled lines
/// (no Kitty graphics). Used when the terminal lacks Kitty
/// support or the chart-mode render failed.
fn drawConvergenceWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const lines = try buildConvergenceLines(state, app, arena);
    const start = @min(app.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
    try app.drawStyledContent(arena, buf, width, height, lines[start..]);
}

/// Build the styled lines for the convergence sub-view's text
/// fallback. Sampled rows for scannability — full data lives in
/// the chart-mode rendering.
fn buildConvergenceLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    const th = app.theme;
    const points = state.convergence_points orelse return try buildEmptyConvergenceLines(arena, th, "  No imported_values.srf data available.");

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

    // Pull from the shared view-model so widths/headings match
    // the CLI exactly. Empty `points` is handled by the view
    // (emits a "no data" line + nothing else); we still want the
    // closing keybind hint, so handle that case below.
    const view_lines = try view.convergenceLines(arena, points);
    const styled = try forecastLinesToStyled(arena, th, view_lines);
    try lines.appendSlice(arena, styled);

    if (points.len > 0) {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        try lines.append(arena, .{
            .text = "  Press 'c' to return to bands view, 'b' for return back-test.",
            .style = th.mutedStyle(),
        });
    }

    return lines.toOwnedSlice(arena);
}

/// "No data" fallback shared by `buildConvergenceLines` and
/// `buildBacktestLines` when the underlying anchor slice is null
/// (load failed or imported_values.srf doesn't exist).
fn buildEmptyConvergenceLines(arena: std.mem.Allocator, th: theme.Theme, msg: []const u8) ![]const StyledLine {
    var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = "  Forecast convergence (spreadsheet's predicted retirement date over time)",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = msg, .style = th.mutedStyle() });
    return lines.toOwnedSlice(arena);
}

/// Render the return-backtest sub-view as a Kitty-graphics chart.
fn drawBacktestWithKitty(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const th = app.theme;
    const anchors = state.backtest_anchors orelse return error.NoData;
    if (anchors.len < 2) return error.NoData;

    // Header: pulled from the shared view-model so the chart
    // path matches the CLI / scroll-fallback exactly. Includes
    // the color-coded legend (purple/cyan/yellow/green).
    const view_header = try view.backtestHeaderLines(arena, anchors, false);
    var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    const styled_header = try forecastLinesToStyled(arena, th, view_header);
    try header_lines.appendSlice(arena, styled_header);

    var footer_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try footer_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try footer_lines.append(arena, .{
        .text = "  Press 'b' to return to bands view, 'c' for convergence.",
        .style = th.mutedStyle(),
    });

    const header_slice = try header_lines.toOwnedSlice(arena);
    try app.drawStyledContent(arena, buf, width, height, header_slice);

    const header_rows: u16 = @intCast(@min(header_slice.len, height));
    const footer_reserve: u16 = @intCast(footer_lines.items.len);
    const chart_rows = height -| header_rows -| footer_reserve;
    if (chart_rows < 6) {
        try drawBacktestWithScroll(state, app, arena, buf, width, height);
        return;
    }

    const cell_size = app.cellPixelSize();
    const px_w: u32 = @as(u32, width -| 2) * cell_size.width;
    const px_h: u32 = @as(u32, chart_rows) * cell_size.height;
    if (px_w < 100 or px_h < 100) return error.TooSmall;
    const capped_w = @min(px_w, app.chart_config.max_width);
    const capped_h = @min(px_h, app.chart_config.max_height);

    if (state.chart_dirty) {
        if (state.image_id) |old_id| {
            if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), old_id);
            state.image_id = null;
        }
        if (app.vx_app) |va| {
            const result = forecast_chart.renderBacktestChart(app.io, app.allocator, anchors, capped_w, capped_h, th) catch {
                state.chart_dirty = false;
                return;
            };
            defer app.allocator.free(result.rgb_data);

            const base64_enc = std.base64.standard.Encoder;
            const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(result.rgb_data.len)) catch {
                state.chart_dirty = false;
                return;
            };
            defer app.allocator.free(b64_buf);
            const encoded = base64_enc.encode(b64_buf, result.rgb_data);

            const img = va.vx.transmitPreEncodedImage(va.tty.writer(), encoded, result.width, result.height, .rgb) catch {
                state.chart_dirty = false;
                return;
            };
            state.image_id = img.id;
            state.image_width = width -| 2;
            state.image_height = chart_rows;
            state.chart_dirty = false;
        }
    }

    if (state.image_id) |img_id| {
        const buf_idx = @as(usize, header_rows) * @as(usize, width) + 1;
        if (buf_idx < buf.len) {
            buf[buf_idx] = .{
                .char = .{ .grapheme = " " },
                .style = th.contentStyle(),
                .image = .{
                    .img_id = img_id,
                    .options = .{
                        .size = .{ .rows = state.image_height, .cols = state.image_width },
                        .scale = .contain,
                    },
                },
            };
        }
    }

    const footer_start_row: usize = @as(usize, header_rows) + @as(usize, chart_rows);
    if (footer_start_row < height) {
        const footer_buf_offset = footer_start_row * @as(usize, width);
        const remaining_buf_len = buf.len -| footer_buf_offset;
        const footer_height: u16 = @intCast(height -| footer_start_row);
        if (remaining_buf_len > 0) {
            try app.drawStyledContent(arena, buf[footer_buf_offset..], width, footer_height, footer_lines.items);
        }
    }
}

/// Render the return-backtest sub-view as scrollable styled lines.
fn drawBacktestWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
    const lines = try buildBacktestLines(state, app, arena);
    const start = @min(app.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
    try app.drawStyledContent(arena, buf, width, height, lines[start..]);
}

fn buildBacktestLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    const th = app.theme;
    const anchors = state.backtest_anchors orelse return try buildEmptyBacktestLines(arena, th, "  No imported_values.srf data available.");

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

    const view_lines = try view.backtestLines(arena, anchors, false);
    const styled = try forecastLinesToStyled(arena, th, view_lines);
    try lines.appendSlice(arena, styled);

    if (anchors.len > 0) {
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
        try lines.append(arena, .{
            .text = "  Press 'b' to return to bands view, 'c' for convergence.",
            .style = th.mutedStyle(),
        });
    }

    return lines.toOwnedSlice(arena);
}

/// "No data" fallback for the back-test sub-view.
fn buildEmptyBacktestLines(arena: std.mem.Allocator, th: theme.Theme, msg: []const u8) ![]const StyledLine {
    var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = "  Expected vs realized return back-test",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = msg, .style = th.mutedStyle() });
    return lines.toOwnedSlice(arena);
}

/// Build the styled-line representation of the projections
/// view (text-only fallback when the chart is hidden, and the
/// scroll body when the chart is visible). File-private — the
/// framework draw hook is `drawContent`, which composes this
/// internally.
fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
    const th = app.theme;
    var lines: std.ArrayListUnmanaged(StyledLine) = .empty;

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

    const ctx = state.ctx orelse {
        try lines.append(arena, .{ .text = "  No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() });
        return lines.toOwnedSlice(arena);
    };

    const comparison = ctx.comparison;
    const config = ctx.config;
    const stock_pct = ctx.stock_pct;

    // As-of indicator — only shown when the tab is displaying a
    // historical snapshot. Muted header note so it doesn't compete
    // with the main content. If the user asked for a date that had no
    // exact snapshot, a second muted line explains the auto-snap.
    if (state.as_of) |actual| {
        const source_label: []const u8 = if (state.ctx) |c| switch (c.as_of_source) {
            .snapshot => "snapshot",
            .imported => "imported",
            .live => "live",
        } else "snapshot";
        const header = try std.fmt.allocPrint(arena, "  As-of: {f}  ({s})", .{ actual, source_label });
        try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });

        if (state.as_of_requested) |requested| {
            if (!requested.eql(actual)) {
                const diff = requested.days - actual.days;
                const note = try std.fmt.allocPrint(
                    arena,
                    "  (requested {f}; snapped back {d} day{s})",
                    .{ requested, diff, fmt.dayPlural(diff) },
                );
                try lines.append(arena, .{ .text = note, .style = th.mutedStyle() });
            }
        }
        if (state.ctx) |c| {
            if (c.as_of_source == .imported) {
                try lines.append(arena, .{
                    .text = "  (bands use today's allocation scaled to the imported liquid total)",
                    .style = th.mutedStyle(),
                });
            }
        }
        if (state.overlay_actuals) {
            const ov_note = try std.fmt.allocPrint(
                arena,
                "  Overlay: actuals from {f}  ·  tracks trajectory, not SWR validity",
                .{actual},
            );
            try lines.append(arena, .{ .text = ov_note, .style = th.infoStyle() });
        }
        try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    }

    // Header
    try lines.append(arena, .{
        .text = "  Benchmark Comparison (price-only weighted return)",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Column headers (accent color to match other tabs)
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{
            "", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
        }),
        .style = th.headerStyle(),
    });

    // Return rows
    var spy_bufs: [5][16]u8 = undefined;
    var spy_label_buf: [32]u8 = undefined;
    const spy_row = view.buildReturnRow(
        view.fmtBenchmarkLabel(&spy_label_buf, config.benchmark_stock, stock_pct * 100),
        comparison.stock_returns,
        &spy_bufs,
        false,
    );
    try appendReturnRow(&lines, arena, th, spy_row);

    var agg_bufs: [5][16]u8 = undefined;
    var agg_label_buf: [32]u8 = undefined;
    const agg_row = view.buildReturnRow(
        view.fmtBenchmarkLabel(&agg_label_buf, config.benchmark_bond, ctx.bond_pct * 100),
        comparison.bond_returns,
        &agg_bufs,
        false,
    );
    try appendReturnRow(&lines, arena, th, agg_row);

    var bench_bufs: [5][16]u8 = undefined;
    const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
    try appendReturnRow(&lines, arena, th, bench_row);

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

    var port_bufs: [5][16]u8 = undefined;
    const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);
    try appendReturnRow(&lines, arena, th, port_row);

    // Projected return (conservative estimate from benchmark analytics)
    {
        var buf: [16]u8 = undefined;
        const cell = view.fmtReturnCell(&buf, comparison.conservative_return);
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s: <30}{s: >8}", .{ "Projected return", cell.text }),
            .style = th.mutedStyle(),
        });
    }

    // Target allocation note
    {
        var note_buf: [128]u8 = undefined;
        if (view.fmtAllocationNote(&note_buf, config.target_stock_pct, stock_pct)) |note| {
            try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
            try lines.append(arena, .{
                .text = try std.fmt.allocPrint(arena, "  {s}", .{note.text}),
                .style = th.styleFor(note.style),
            });
        }
    }

    // Accumulation phase / Earliest retirement blocks. Use the
    // historical snapshot date when one is configured so the
    // promoted date and earliest-retirement grid anchor on the
    // same reference point as the rest of the as-of-mode display.
    const ref_date = state.as_of orelse app.today;
    try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date);

    // Braille chart: median portfolio value over the longest horizon
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    const horizons = config.getHorizons();
    if (horizons.len > 0) {
        const last_idx = horizons.len - 1;
        if (ctx.data.bands[last_idx]) |bands| {
            if (bands.len >= 2) {
                try lines.append(arena, .{
                    .text = try std.fmt.allocPrint(arena, "  Median Portfolio Value ({d}-Year, 99% withdrawal)", .{horizons[last_idx]}),
                    .style = th.headerStyle(),
                });
                try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

                // Synthesize candles from median values
                const candles = try arena.alloc(zfin.Candle, bands.len);
                for (bands, 0..) |bp, i| {
                    const v: f32 = @floatCast(bp.p50);
                    candles[i] = .{
                        .date = zfin.Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 365)),
                        .open = v,
                        .high = v,
                        .low = v,
                        .close = v,
                        .adj_close = v,
                        .volume = 0,
                    };
                }

                // Compute braille chart with wider dimensions
                const chart_width: usize = 80;
                const chart_height: usize = 12;
                var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;

                if (br) |*br_chart| {
                    const bg = th.bg;
                    const muted_fg = theme.Theme.vcolor(th.text_muted);
                    const bg_v = theme.Theme.vcolor(bg);

                    // Cell budget per row: 2 leading spaces + n_cols
                    // chart cells + 1 separator space + up to
                    // `money_label_max_bytes` for the price label.
                    // Sized via a named constant so the projection
                    // chart doesn't silently truncate labels for
                    // portfolios that grow past $1M; see
                    // `fmt.money_label_max_bytes`.
                    const proj_label_cells: usize = 1 + fmt.money_label_max_bytes;
                    const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells;

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

                        // 2 leading spaces
                        graphemes[gpos] = " ";
                        styles[gpos] = .{ .fg = muted_fg, .bg = bg_v };
                        gpos += 1;
                        graphemes[gpos] = " ";
                        styles[gpos] = styles[0];
                        gpos += 1;

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

                        // Right-side price labels
                        if (row == 0 or row == br_chart.chart_height - 1) {
                            const lbl = if (row == 0) br_chart.maxLabel() else br_chart.minLabel();
                            const lbl_full = try std.fmt.allocPrint(arena, " {s}", .{lbl});
                            for (lbl_full) |ch| {
                                if (gpos < graphemes.len) {
                                    graphemes[gpos] = tui.glyph(ch);
                                    styles[gpos] = .{ .fg = muted_fg, .bg = bg_v };
                                    gpos += 1;
                                }
                            }
                        }

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

                    // Year axis: "Now" on left, "{horizon}yr" on right
                    {
                        const axis_graphemes = try arena.alloc([]const u8, proj_row_cells);
                        const axis_styles = try arena.alloc(vaxis.Style, proj_row_cells);
                        const muted_style = vaxis.Style{ .fg = muted_fg, .bg = bg_v };
                        var apos: usize = 0;

                        // "  Now"
                        for ("  Now") |ch| {
                            axis_graphemes[apos] = tui.glyph(ch);
                            axis_styles[apos] = muted_style;
                            apos += 1;
                        }

                        // Padding to right-align the end label
                        const end_label = try std.fmt.allocPrint(arena, "{d}yr", .{horizons[last_idx]});
                        const n_pad = if (br_chart.n_cols + 2 > 3 + end_label.len) br_chart.n_cols + 2 - 3 - end_label.len else 0;
                        for (0..n_pad) |_| {
                            axis_graphemes[apos] = " ";
                            axis_styles[apos] = muted_style;
                            apos += 1;
                        }

                        for (end_label) |ch| {
                            if (apos < axis_graphemes.len) {
                                axis_graphemes[apos] = tui.glyph(ch);
                                axis_styles[apos] = muted_style;
                                apos += 1;
                            }
                        }

                        try lines.append(arena, .{
                            .text = "",
                            .style = muted_style,
                            .graphemes = axis_graphemes[0..apos],
                            .cell_styles = axis_styles[0..apos],
                        });
                    }
                }
            }
        }
    }

    // Portfolio value at end of horizon (nominal, using 99% withdrawal)
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = "  Terminal Portfolio Value (nominal, at 99% withdrawal rate)",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    // Column header
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
        .style = th.headerStyle(),
    });

    // Percentile rows
    {
        const all_bands = ctx.data.bands;
        const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
        const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
        for (p_labels, p_styles, 0..) |plabel, pstyle, pi| {
            const row = try view.buildPercentileRow(arena, plabel, pi, all_bands, pstyle);
            try lines.append(arena, .{
                .text = try std.fmt.allocPrint(arena, "  {s}", .{row.text}),
                .style = th.styleFor(row.style),
            });
        }
    }

    // Safe withdrawal table
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
    try lines.append(arena, .{
        .text = "  Safe Withdrawal (FIRECalc historical simulation)",
        .style = th.headerStyle(),
    });
    try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}),
        .style = th.headerStyle(),
    });

    const cached_wr = ctx.data.withdrawals;
    const confidence_levels = config.getConfidenceLevels();
    for (confidence_levels, 0..) |conf, ci| {
        const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci);
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s}", .{rows.amount.text}),
            .style = th.contentStyle(),
        });
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  {s}", .{rows.rate.text}),
            .style = th.mutedStyle(),
        });
    }

    // Life events summary (at the bottom)
    try appendEventSummary(&lines, app.today, arena, th, ctx);

    return lines.toOwnedSlice(arena);
}

// ── Helpers ───────────────────────────────────────────────────

fn appendReturnRow(
    lines: *std.ArrayListUnmanaged(StyledLine),
    arena: std.mem.Allocator,
    th: theme.Theme,
    row: view.ReturnRow,
) !void {
    // SPY/AGG (not bold) in muted; Benchmark/Portfolio (bold) in content style.
    const style = if (row.bold) th.contentStyle() else th.mutedStyle();
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{
            row.label,
            row.one_year.text,
            row.three_year.text,
            row.five_year.text,
            row.ten_year.text,
            row.week.text,
        }),
        .style = style,
    });
}

/// Key handler for the date-input modal (`d` keybind on
/// projections). Accepts the same input as the CLI `--as-of`
/// flag — `YYYY-MM-DD`, relative shortcuts (`1W`, `1M`, `3M`,
/// `1Q`, `1Y`, `3Y`, `5Y`), or `live` / empty for live state.
/// Commit via Enter, cancel via Esc.
///
/// Returns `true` for any consumed key. Always consumes:
/// modal contract — keys can't leak through to global keymap
/// matching while the prompt is open. Cleanup of
/// `state.modal` and `app.input_len` happens here on
/// cancel/commit; the shared `handleInputBuffer` no longer
/// touches mode/modal state (its callers do).
fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool {
    switch (input_buffer.handleKey(&app.input_buf, &app.input_len, key)) {
        .cancelled => {
            state.modal = .none;
            app.setStatus("Cancelled");
            return true;
        },
        .edited => return true,
        .ignored => return true,
        .committed => {
            const input = app.input_buf[0..app.input_len];
            const parsed = cli.parseAsOfDate(input, app.today) catch |err| {
                var buf: [256]u8 = undefined;
                const msg = cli.fmtAsOfParseError(&buf, input, err);
                app.setStatus(msg);
                state.modal = .none;
                app.input_len = 0;
                return true;
            };

            if (parsed) |d| {
                // Guard against future dates.
                if (d.days > app.today.days) {
                    app.setStatus("As-of date is in the future");
                    state.modal = .none;
                    app.input_len = 0;
                    return true;
                }
                state.as_of = d;
                state.as_of_requested = null;
                var status_buf: [64]u8 = undefined;
                const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
                app.setStatus(msg);
            } else {
                // `null` parse result = live.
                state.as_of = null;
                state.as_of_requested = null;
                app.setStatus("As-of cleared — showing live");
            }

            tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err});

            state.modal = .none;
            app.input_len = 0;
            return true;
        },
    }
}

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

const testing = std.testing;

test "formatOverlayUnavailable: includes resolved as-of-input key" {
    var buf: [128]u8 = undefined;
    const msg = try formatOverlayUnavailable(&buf, "d");
    try testing.expectEqualStrings("Overlay only available with --as-of (press d to set)", msg);
}

test "formatOverlayUnavailable: respects rebound as-of-input key" {
    var buf: [128]u8 = undefined;
    const msg = try formatOverlayUnavailable(&buf, "ctrl+d");
    try testing.expectEqualStrings("Overlay only available with --as-of (press ctrl+d to set)", msg);
}
