/// CLI `projections` command: retirement projections and benchmark comparison.
///
/// Produces:
///   - Benchmark comparison table (SPY/AGG vs portfolio weighted returns)
///   - Conservative weighted return estimate
///   - Safe withdrawal amounts at multiple horizons and confidence levels
///
/// When `as_of` is non-null, the same output is produced against a
/// historical snapshot instead of the live portfolio. See
/// `src/views/projections.zig:loadProjectionContextAsOf`.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Date = zfin.Date;
const Money = @import("../Money.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const imported = @import("../data/imported_values.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");

/// Tagged-union args for the four projection sub-modes. Mutually-
/// exclusive flag combos (--convergence with --vs, --real with
/// non-backtest, etc.) are rejected at parse time so each variant
/// here is a self-contained mode.
pub const ParsedArgs = union(enum) {
    /// Default: percentile bands view. `as_of` selects the snapshot
    /// (or today for live). `overlay_actuals` plots realized
    /// trajectory on top.
    bands: BandsArgs,
    /// `--vs <DATE>`: side-by-side compare of two projections.
    compare: CompareArgs,
    /// `--convergence`: plot the spreadsheet's predicted retirement
    /// date over time. No knobs.
    convergence,
    /// `--return-backtest [--real]`: plot expected_return vs realized
    /// forward-CAGR.
    return_backtest: struct { real: bool },
};

pub const BandsArgs = struct {
    events_enabled: bool = true,
    /// `null` means live (today). Non-null = historical snapshot.
    as_of: ?Date = null,
    overlay_actuals: bool = false,
    /// When set, render the percentile-band chart (with optional
    /// overlay) as a PNG to this path and exit. No text output.
    /// Only supported in the default bands mode; --convergence,
    /// --return-backtest, and --vs reject the flag at parse time.
    export_chart: ?[]const u8 = null,
};

pub const CompareArgs = struct {
    events_enabled: bool = true,
    vs_date: Date,
    /// "Now" side. Null = today (live); non-null = the `--as-of`
    /// date the user paired with `--vs`.
    as_of: ?Date = null,
};

pub const meta: framework.Meta = .{
    .name = "projections",
    .group = .portfolio,
    .synopsis = "Retirement projections, benchmark comparison, percentile bands",
    .help =
    \\Usage: zfin projections [opts]
    \\
    \\Default mode: percentile-bands view of the portfolio's
    \\projected value over the configured horizon (`projections.srf`),
    \\plus benchmark comparison (SPY/AGG) and safe-withdrawal
    \\dollars at multiple horizons / confidence levels.
    \\
    \\Three alternate sub-modes (mutually exclusive):
    \\  --vs <DATE>            Side-by-side compare with a
    \\                         historical snapshot's projection.
    \\  --convergence          Plot the model's predicted
    \\                         retirement date over time as data
    \\                         accumulated.
    \\  --return-backtest      Plot expected_return claim over
    \\                         time alongside realized forward
    \\                         CAGR. Pair with `--real` for
    \\                         CPI-adjusted dollars.
    \\
    \\Options:
    \\  --no-events            Exclude life events from the
    \\                         simulation (baseline view).
    \\  --as-of <DATE>         Compute against a historical
    \\                         snapshot. Auto-snaps to the
    \\                         nearest-earlier snapshot.
    \\  --overlay-actuals      Plot realized portfolio trajectory
    \\                         from --as-of through today on top
    \\                         of the percentile bands. Requires
    \\                         --as-of. Ignored under --vs.
    \\  --vs <DATE>            (see above)
    \\  --convergence          (see above)
    \\  --return-backtest      (see above)
    \\  --real                 With --return-backtest, render in
    \\                         CPI-adjusted dollars.
    \\  --export-chart <PATH>  Render the percentile-band chart
    \\                         (with optional overlay if
    \\                         --overlay-actuals is set) as a PNG
    \\                         to PATH (1920x1080) and exit. Only
    \\                         valid in the default bands mode.
    \\
    \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, MutuallyExclusive, NoSnapshot, PortfolioLoadFailed },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    const io = ctx.io;
    const today = ctx.today;

    var events_enabled = true;
    var as_of: ?Date = null;
    var vs_date: ?Date = null;
    var overlay_actuals = false;
    var convergence = false;
    var return_backtest = false;
    var real_mode = false;
    var export_chart: ?[]const u8 = null;

    var i: usize = 0;
    while (i < cmd_args.len) : (i += 1) {
        const a = cmd_args[i];
        if (std.mem.eql(u8, a, "--no-events")) {
            events_enabled = false;
        } else if (std.mem.eql(u8, a, "--overlay-actuals")) {
            overlay_actuals = true;
        } else if (std.mem.eql(u8, a, "--convergence")) {
            convergence = true;
        } else if (std.mem.eql(u8, a, "--return-backtest")) {
            return_backtest = true;
        } else if (std.mem.eql(u8, a, "--real")) {
            real_mode = true;
        } else if (std.mem.eql(u8, a, "--export-chart")) {
            export_chart = try cli.requireFlagValue(io, cmd_args, &i, a);
        } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
            if (i + 1 >= cmd_args.len) {
                cli.stderrPrint(io, "Error: ");
                cli.stderrPrint(io, a);
                cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
                return error.MissingFlagValue;
            }
            const value = cmd_args[i + 1];
            const parsed_date = cli.parseAsOfDate(value, today) catch |err| {
                var buf: [256]u8 = undefined;
                const msg = cli.fmtAsOfParseError(&buf, value, err);
                cli.stderrPrint(io, msg);
                cli.stderrPrint(io, "\n");
                return error.InvalidFlagValue;
            };
            if (parsed_date) |d| {
                if (d.days > today.days) {
                    cli.stderrPrint(io, "Error: date is in the future.\n");
                    return error.InvalidFlagValue;
                }
                if (std.mem.eql(u8, a, "--as-of")) {
                    as_of = d;
                } else {
                    vs_date = d;
                }
            }
            // null (= "live") is ignored — leaves flag unset, same
            // as not passing the flag at all.
            i += 1;
        } else {
            cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
            cli.stderrPrint(io, a);
            cli.stderrPrint(io, "\n");
            return error.UnexpectedArg;
        }
    }

    // Mutually-exclusive view flags. Forecast-evaluation flags
    // (`--convergence`, `--return-backtest`) replace the default
    // bands view entirely; combining them with each other,
    // `--vs`, or `--overlay-actuals` is rejected.
    if (convergence and return_backtest) {
        cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
        return error.MutuallyExclusive;
    }
    if ((convergence or return_backtest) and vs_date != null) {
        cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
        return error.MutuallyExclusive;
    }
    if ((convergence or return_backtest) and overlay_actuals) {
        cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
        return error.MutuallyExclusive;
    }
    if (real_mode and !return_backtest) {
        cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
        return error.MutuallyExclusive;
    }
    // Chart export only meaningful in default bands mode. The
    // forecast-evaluation views (convergence, return-backtest)
    // render via `forecast_chart.zig` which doesn't have a PNG
    // export path yet; --vs is text-only with no chart at all.
    if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
        cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
        return error.MutuallyExclusive;
    }

    if (convergence) return ParsedArgs{ .convergence = {} };
    if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
    if (vs_date) |d| {
        return ParsedArgs{ .compare = .{
            .events_enabled = events_enabled,
            .vs_date = d,
            .as_of = as_of,
        } };
    }
    return ParsedArgs{ .bands = .{
        .events_enabled = events_enabled,
        .as_of = as_of,
        .overlay_actuals = overlay_actuals,
        .export_chart = export_chart,
    } };
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const today = ctx.today;

    const pf = ctx.resolvePortfolioPath();
    defer pf.deinit(allocator);
    const file_path = pf.path;

    switch (parsed) {
        .convergence => try runConvergence(io, allocator, file_path, color, out),
        .return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
        .compare => |args| {
            _ = ctx.svc orelse return error.MissingDataService;
            // Pre-load live data when the "now" side is live so it
            // can be shared with `computeKeyComparison`. The
            // snapshot-vs-snapshot path doesn't need it.
            const now_is_live = args.as_of == null;
            var live: ?LiveData = null;
            defer if (live) |*l| l.deinit(allocator);
            if (now_is_live) {
                live = try loadLiveData(ctx, today, color);
            }
            try runCompare(
                ctx,
                file_path,
                .{
                    .events_enabled = args.events_enabled,
                    .vs_date = args.vs_date,
                    .now_date = args.as_of orelse today,
                    .now_from_snapshot = args.as_of != null,
                    .live_for_now = if (live) |*l| l else null,
                },
            );
        },
        .bands => |args| {
            _ = ctx.svc orelse return error.MissingDataService;
            // Pre-load live data unconditionally for the bands view.
            // The live (no `--as-of`) path needs it for the simulation;
            // the imported-only as-of branch (when `--as-of <DATE>`
            // resolves to an imported value rather than a snapshot)
            // needs today's allocations to scale to the imported
            // total. Snapshot-only as-of paths ignore it.
            var live = try loadLiveData(ctx, today, color);
            defer if (live) |*l| l.deinit(allocator);
            try runBands(
                io,
                allocator,
                ctx.svc.?,
                file_path,
                .{
                    .events_enabled = args.events_enabled,
                    .as_of = args.as_of orelse today,
                    .from_snapshot = args.as_of != null,
                    .today = today,
                    .overlay_actuals = args.overlay_actuals,
                    .export_chart = args.export_chart,
                    .live = if (live) |*l| l else null,
                },
                color,
                out,
            );
        },
    }
}

/// How an as-of date resolved against the history directory. The CLI
/// uses this to render a single header that tells the user what
/// actually got loaded (exact hit, nearest-earlier, or straight-up
/// "no snapshot available").
const AsOfResolution = struct {
    /// The requested date, as parsed by the caller.
    requested: Date,
    /// The date that was actually loaded. Differs from `requested`
    /// when we auto-snapped to the nearest-earlier data point.
    actual: Date,
    /// Whether the resolution landed on a native snapshot or an
    /// `imported_values.srf` row. Snapshot wins when both are
    /// available within the at-or-before window.
    source: history.AsOfSourceKind = .snapshot,
    /// Liquid total at `actual`. Filled when `source == .imported`
    /// (read directly from the imported_values row); zero otherwise
    /// — the snapshot path reads its totals from the loaded snapshot.
    liquid: f64 = 0,
};

/// Run percentile-bands projection (the default mode).
///
/// `as_of` is the reference date for ages, horizons, and snapshot
/// windows. `from_snapshot` selects the data source:
///   - `false`: live mode. Load `file_path` directly. Caller passes
///     today as `as_of`.
///   - `true`: historical mode. Load the snapshot at-or-before
///     `as_of` from the history dir.
/// Pre-loaded live-portfolio data used by `runBands` and
/// `computeKeyComparison`. The caller (typically `run()`) loads
/// this via `cli.loadPortfolio(ctx, today)` so the multi-file
/// union-merge path is always taken — matching what the TUI sees
/// and what every other CLI command sees.
///
/// Loading lives in the caller (not in `runBands` /
/// `computeKeyComparison`) so the helpers can't accidentally take
/// a single-file path. They consume what the caller passes in;
/// the union-merge contract is enforced at one site.
///
/// `prices` is retained alongside the summary so callers that
/// need per-symbol price lookups (e.g. `compare`'s LiveSide via
/// `compare_core.aggregateLiveStocks`) can borrow without
/// re-fetching.
///
/// Caller owns the fields and must call `deinit` after the helper
/// returns.
pub const LiveData = struct {
    loaded: cli.LoadedPortfolio,
    pf_data: cli.PortfolioData,
    prices: std.StringHashMap(f64),

    pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void {
        self.prices.deinit();
        self.pf_data.deinit(allocator);
        self.loaded.deinit(allocator);
    }
};

/// Load the live portfolio (multi-file union-merge), fetch prices
/// per the resolved refresh policy, and build the
/// summary/candle-map bundle. Mirrors what every other CLI command
/// does via `cli.loadPortfolio` + `cli.loadPortfolioPrices` +
/// `cli.buildPortfolioData`.
///
/// Returns `null` when the portfolio fails to load (the loader has
/// already printed a stderr message). On `error.NoAllocations` /
/// `error.SummaryFailed` the helper writes a friendly stderr line
/// and returns `null` so the caller can fall through cleanly.
pub fn loadLiveData(
    ctx: *framework.RunCtx,
    today: Date,
    color: bool,
) !?LiveData {
    const io = ctx.io;
    const allocator = ctx.allocator;
    const svc = ctx.svc orelse return error.MissingDataService;

    var loaded = cli.loadPortfolio(ctx, today) orelse return null;
    errdefer loaded.deinit(allocator);

    var prices = std.StringHashMap(f64).init(allocator);
    errdefer prices.deinit();
    {
        var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color);
        defer load_result.deinit();
        var it = load_result.prices.iterator();
        while (it.next()) |entry| {
            try prices.put(entry.key_ptr.*, entry.value_ptr.*);
        }
    }

    const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) {
        error.NoAllocations, error.SummaryFailed => {
            cli.stderrPrint(io, "Error computing portfolio summary.\n");
            return null;
        },
        else => return err,
    };

    return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
}

/// Per-call configuration for `runBands`. Bundled because the
/// call already had nine context-plus-config parameters and adding
/// `refresh` would push it past the readable-positional threshold.
/// Same rationale as `KeyComparisonOptions` — see its doc-block.
pub const BandsOptions = struct {
    /// Whether simulated lifecycle events (RMDs, lump-sum
    /// withdrawals, Social Security) are baked into the
    /// projection.
    events_enabled: bool,
    /// Reference date for the projection. When `from_snapshot`
    /// is false this also doubles as "today" for cash/CD
    /// computation paths that resolve from the live portfolio.
    as_of: Date,
    /// True when `as_of` came from `--as-of`. Selects the
    /// historical-snapshot resolution path; otherwise the
    /// live-portfolio path is used.
    from_snapshot: bool,
    /// The actual current calendar day. Used for the live-side
    /// composition and cash totals when `from_snapshot` is
    /// false. May equal `as_of` in the live case.
    today: Date,
    /// Whether to overlay an actual-history series on the
    /// projection percentile bands.
    overlay_actuals: bool,
    /// When set, render the percentile-band chart (with optional
    /// overlay) to a PNG at this path and exit before any text
    /// output renders. See `chart_export.exportProjectionChart`.
    export_chart: ?[]const u8 = null,
    /// Pre-loaded live-portfolio data. Required when:
    ///   - `from_snapshot == false` (the live-portfolio path), OR
    ///   - `from_snapshot == true` AND the as-of resolution lands
    ///     on an `imported_values.srf` row (the imported-only
    ///     branch needs today's allocations to scale).
    /// Snapshot-only as-of paths ignore this field. See
    /// `LiveData` for the rationale.
    live: ?*const LiveData = null,
};

pub fn runBands(
    io: std.Io,
    allocator: std.mem.Allocator,
    svc: *zfin.DataService,
    file_path: []const u8,
    opts: BandsOptions,
    color: bool,
    out: *std.Io.Writer,
) !void {
    // Single arena for all view/render allocations. Same lifetime
    // regardless of live vs. as-of path.
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();
    const va = arena_state.allocator();

    // portfolio_dir is the directory component of file_path, ending
    // in a separator (for the downstream `{s}projections.srf` join).
    const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
    const portfolio_dir = file_path[0..dir_end];

    // Build the context via either the live or as-of pipeline. Both
    // produce a `ProjectionContext`; from that point on rendering is
    // identical.
    // SAFETY: ctx is fully written by the live or as-of branch
    // below before any read. Both branches assign `ctx.* = ...`
    // before falling through to the rendering code.
    var ctx: view.ProjectionContext = undefined;
    var resolution: ?AsOfResolution = null;
    // Snapshot must outlive the context when on the as-of path because
    // `ctx.allocations` borrow their symbol strings from the snapshot's
    // backing buffer. Keep this declared at the outer scope so the
    // defer runs at the end of `run`.
    var snap_bundle: ?history.LoadedSnapshot = null;
    defer if (snap_bundle) |*s| s.deinit(allocator);

    if (opts.from_snapshot) {
        resolution = resolveAsOfSnapshot(io, va, file_path, opts.as_of) catch |err| switch (err) {
            error.NoSnapshot => return,
            else => return err,
        };

        if (resolution.?.source == .snapshot) {
            const hist_dir = try history.deriveHistoryDir(va, file_path);
            snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual);

            ctx = try view.loadProjectionContextAsOf(
                io,
                va,
                portfolio_dir,
                &snap_bundle.?.snap,
                resolution.?.actual,
                svc,
                opts.events_enabled,
            );
        } else {
            // Imported-only as-of: need today's portfolio composition
            // (allocations + cash/CD totals) to scale to the imported
            // liquid value. Caller supplies it in `opts.live`.
            const live = opts.live orelse {
                cli.stderrPrint(io, "Error: imported-only as-of resolution requires live portfolio data; pass `opts.live`.\n");
                return;
            };
            const lp = &live.loaded;

            ctx = try view.loadProjectionContextFromImported(
                io,
                va,
                portfolio_dir,
                live.pf_data.summary.allocations,
                live.pf_data.summary.total_value,
                lp.portfolio.totalCash(opts.today),
                lp.portfolio.totalCdFaceValue(opts.today),
                resolution.?.liquid,
                resolution.?.actual,
                svc,
                opts.events_enabled,
            );
        }
    } else {
        // Live path. Caller supplies pre-loaded data in `opts.live`.
        const live = opts.live orelse {
            cli.stderrPrint(io, "Error: live projections require pre-loaded `opts.live` data.\n");
            return;
        };
        const lp = &live.loaded;

        ctx = try view.loadProjectionContext(
            io,
            va,
            portfolio_dir,
            live.pf_data.summary.allocations,
            live.pf_data.summary.total_value,
            lp.portfolio.totalCash(opts.as_of),
            lp.portfolio.totalCdFaceValue(opts.as_of),
            svc,
            opts.events_enabled,
            opts.as_of,
        );
    }

    // ── Optional actuals overlay ────────────────────────────────
    //
    // Requires `--as-of` (an as-of-resolved snapshot date). When the
    // user passes `--overlay-actuals` without `--as-of`, warn and
    // continue without the overlay; the overlay against today-as-now
    // is meaningless because the future hasn't happened yet.
    if (opts.overlay_actuals) {
        if (!opts.from_snapshot) {
            cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
        } else if (resolution) |r| {
            ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: {
                // Non-fatal — the projection still renders without
                // the overlay. Surface the error so the user can fix
                // their history dir but don't block the report.
                var buf: [256]u8 = undefined;
                const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n";
                cli.stderrPrint(io, msg);
                break :blk null;
            };
        }
    }

    // ── PNG export short-circuit ─────────────────────────────────
    //
    // When --export-chart is set, render the percentile-band chart
    // (with overlay if loaded) to the requested PNG path and exit
    // before any text output. Uses the longest configured horizon —
    // matching what the TUI shows by default.
    if (opts.export_chart) |export_path| {
        const horizons_ec = ctx.config.getHorizons();
        if (horizons_ec.len == 0) {
            cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
            return;
        }
        const last_idx_ec = horizons_ec.len - 1;
        const bands_ec = ctx.data.bands[last_idx_ec] orelse {
            cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
            return;
        };

        // Translate the view-layer overlay points (if any) into the
        // chart-module's ActualsPoint shape. Same conversion the TUI
        // does in `projections_tab.drawWithKittyChart`.
        var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
        defer if (overlay_buf) |ob| va.free(ob);
        const overlay_input = blk: {
            const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
            const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
            for (ov.points, 0..) |p, i| {
                buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
            }
            overlay_buf = buf;
            break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
                .points = buf,
                .today_years = ov.today_years,
            };
        };

        chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
            error.InsufficientData => {
                cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
                return;
            },
            else => {
                cli.stderrPrint(io, "Error: failed to write PNG.\n");
                return err;
            },
        };
        return;
    }

    const horizons = ctx.config.getHorizons();
    const confidence_levels = ctx.config.getConfidenceLevels();
    const comparison = ctx.comparison;

    try out.print("\n", .{});
    if (resolution) |r| {
        const source_label: []const u8 = switch (r.source) {
            .snapshot => "snapshot",
            .imported => "imported value",
        };
        try cli.printBold(out, color, "Projections (as of {f}, {s})\n", .{ r.actual, source_label });
    } else {
        try cli.printBold(out, color, "Projections ({s})\n", .{file_path});
    }
    try out.print("========================================\n", .{});

    // If auto-snapped, print a muted note so the user knows the
    // requested date wasn't an exact hit. The wording reflects the
    // resolution source — "nearest snapshot" vs "nearest imported
    // value" — so the user knows which file to update for finer
    // granularity.
    if (resolution) |r| {
        if (r.actual.days != r.requested.days) {
            const diff = r.requested.days - r.actual.days;
            const nearest_label: []const u8 = switch (r.source) {
                .snapshot => "nearest snapshot",
                .imported => "nearest imported value",
            };
            try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f}; {s}: {f}, {d} day{s} earlier)\n", .{
                r.requested,
                nearest_label,
                r.actual,
                diff,
                fmt.dayPlural(diff),
            });
        }
        if (r.source == .imported) {
            try cli.printFg(out, color, cli.CLR_MUTED, "(bands use today's allocation scaled to the imported liquid total)\n", .{});
        }
    }
    try out.print("\n", .{});

    // Section title. Includes a methodology note so a reader
    // comparing these numbers against the `history` tab's window
    // table doesn't get tripped up by the (legitimate)
    // disagreement: this table reports each row as a weighted
    // price-only return per period (per-symbol price change ×
    // current weight, summed) so SPY/AGG/Benchmark/Your
    // Portfolio rows are apples-to-apples; history's window
    // table reports snapshot-to-snapshot Liquid value deltas
    // that include contributions, withdrawals, and weight drift.
    try cli.setBold(out, color);
    try out.print("Benchmark comparison (price-only weighted return)\n", .{});
    try cli.reset(out, color);

    // Header row
    try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}\n", .{
        "", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
    });

    // Build return rows via view model
    var spy_bufs: [5][16]u8 = undefined;
    var spy_label_buf: [32]u8 = undefined;
    const spy_row = view.buildReturnRow(
        view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmark_stock, ctx.stock_pct * 100),
        comparison.stock_returns,
        &spy_bufs,
        false,
    );

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

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

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

    const rows = [_]view.ReturnRow{ spy_row, agg_row, bench_row };
    for (rows) |row| {
        if (row.bold) try cli.setBold(out, color);
        try writeReturnRow(out, color, row);
        if (row.bold) try cli.reset(out, color);
    }

    try out.print("\n", .{});

    try cli.setBold(out, color);
    try writeReturnRow(out, color, port_row);
    try cli.reset(out, color);

    // Projected return (conservative estimate from benchmark analytics)
    {
        var buf: [16]u8 = undefined;
        const cell = view.fmtReturnCell(&buf, comparison.conservative_return);
        try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}\n", .{ "Projected return", cell.text });
    }

    // Target allocation note
    {
        var note_buf: [128]u8 = undefined;
        if (view.fmtAllocationNote(&note_buf, ctx.config.target_stock_pct, ctx.stock_pct)) |note| {
            try out.print("\n", .{});
            try cli.printIntent(out, color, note.style, "{s}\n", .{note.text});
        }
    }

    // ── Accumulation phase / Earliest retirement blocks ──────────
    try renderAccumulationBlock(out, color, va, ctx);
    try renderEarliestBlock(out, color, va, ctx, opts.as_of);

    // ── Braille chart: median portfolio value ─────────────────────
    if (horizons.len > 0) {
        const last_idx = horizons.len - 1;
        if (ctx.data.bands[last_idx]) |b| {
            if (b.len >= 2) {
                try out.print("\n", .{});
                try cli.printBold(out, color, "Median Portfolio Value ({d}-Year, 99% withdrawal)\n\n", .{horizons[last_idx]});

                // Synthesize candles from median values
                const candles = try va.alloc(zfin.Candle, b.len);
                for (b, 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,
                    };
                }

                var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
                if (br) |*chart| {
                    try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
                    // Year axis instead of date axis
                    try cli.setFg(out, color, cli.CLR_MUTED);
                    try out.print("  Now", .{});
                    const end_label_buf = try std.fmt.allocPrint(va, "{d}yr", .{horizons[last_idx]});
                    const pad = if (chart.n_cols > 3 + end_label_buf.len) chart.n_cols - 3 - end_label_buf.len else 0;
                    for (0..pad) |_| try out.print(" ", .{});
                    try out.print("{s}\n", .{end_label_buf});
                    try cli.reset(out, color);
                }
            }
        }
    }

    // Overlay-actuals tip: the CLI's braille chart is single-series,
    // so the actuals overlay only renders in the TUI. Print a short
    // pointer so the user knows where to find it. (We do NOT gate on
    // ctx.overlay_actuals being non-null — even when the overlay was
    // requested but had no data, the user benefits from the tip.)
    if (opts.overlay_actuals and opts.from_snapshot) {
        try cli.printFg(out, color, cli.CLR_MUTED, "  (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{});
        try cli.printFg(out, color, cli.CLR_MUTED, "  Caveat: overlay tracks trajectory, not SWR validity.\n", .{});
    }

    // ── Terminal portfolio value ─────────────────────────────────
    try out.print("\n", .{});
    try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{});

    try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)});

    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(va, plabel, pi, ctx.data.bands, pstyle);
        try cli.printIntent(out, color, row.style, "{s}\n", .{row.text});
    }

    // ── Safe withdrawal table ──────────────────────────────────
    try out.print("\n", .{});
    try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{});

    // Header row
    try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});

    // Withdrawal rows
    for (confidence_levels, 0..) |conf, ci| {
        const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
        try out.print("{s}\n", .{wr_rows.amount.text});
        try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
    }

    // Life events summary — both as-of and live modes resolve ages
    // against the reference date (`resolution.actual` if a snapshot
    // was loaded, otherwise `as_of` directly).
    {
        const events = ctx.config.getEvents();
        if (events.len > 0) {
            const ages_ref_date = if (resolution) |r| r.actual else opts.as_of;
            const ages = ctx.config.currentAges(ages_ref_date);
            try out.print("\n", .{});
            try cli.printBold(out, color, "Life Events\n", .{});
            for (events) |*ev| {
                const line = try view.fmtEventLine(va, ev, &ages);
                try cli.printIntent(out, color, line.style, "{s}\n", .{line.text});
            }
        }
    }

    try out.print("\n", .{});
}

/// Key numbers extracted from a fully-built `ProjectionContext` for
/// email-header headline comparison. Bundles what `zfin compare`'s
/// attribution line needs alongside what the weekly review email's
/// "Projected Return" and "1st Year Withdrawal" rows cite.
pub const KeyMetrics = struct {
    /// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
    /// per position, weighted). Rendered under the label
    /// "Projected return" — matches the email's column header.
    projected_return: f64,
    /// Safe withdrawal amount at longest horizon × 99% confidence.
    /// This is the "retirement now, 1st year withdrawal" number the
    /// email cites.
    swr_99: f64,
    /// `swr_99 / total_value`. Rendered as a percent alongside the
    /// dollar amount for comparison sanity.
    swr_99_rate: f64,
    /// Longest configured horizon, in years. Used for the "this is
    /// at the {N}-year horizon" caption above the comparison block
    /// so the reader doesn't have to remember which horizon
    /// `swr_99` refers to. Comes from `ctx.config.getHorizons()`.
    horizon_years: u16,
};

fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
    const horizons = ctx.config.getHorizons();
    const longest = horizons.len - 1;
    const swr = ctx.data.withdrawals[ctx.data.ci_99 * horizons.len + longest];
    const rate = if (ctx.total_value > 0) swr.annual_amount / ctx.total_value else 0.0;
    return .{
        .projected_return = ctx.comparison.conservative_return,
        .swr_99 = swr.annual_amount,
        .swr_99_rate = rate,
        .horizon_years = horizons[longest],
    };
}

/// Build a `ProjectionContext` against a historical snapshot date.
///
/// Caller owns `snap_bundle_out.*` on success - it must outlive the
/// returned context because allocations borrow symbol strings from
/// the snapshot's backing buffer.
///
/// Imported-only resolutions (where the requested date predates any
/// real snapshot but is covered by `imported_values.srf`) are NOT
/// supported here: the imported-only path needs live-portfolio
/// composition plumbed through additional outparams that this helper
/// doesn't expose. Callers that hit this case get `error.NoSnapshot`
/// after a clear stderr message, mirroring the user-visible behavior
/// of "no snapshot at that date." See the `--vs` follow-up TODO for
/// the parity work that would make this branch fully supported.
fn loadAsOfContext(
    io: std.Io,
    allocator: std.mem.Allocator,
    va: std.mem.Allocator,
    svc: *zfin.DataService,
    file_path: []const u8,
    portfolio_dir: []const u8,
    events_enabled: bool,
    requested_date: Date,
    resolution_out: *AsOfResolution,
    snap_bundle_out: *history.LoadedSnapshot,
) !view.ProjectionContext {
    resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
    if (resolution_out.source != .snapshot) {
        // Imported-only resolution: no snapshot file exists at the
        // resolved date, so `loadSnapshotAt` would crash with
        // FileNotFound. Bail with a clear message instead.
        cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
        return error.NoSnapshot;
    }
    const hist_dir = try history.deriveHistoryDir(va, file_path);
    snap_bundle_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution_out.actual);
    return try view.loadProjectionContextAsOf(
        io,
        va,
        portfolio_dir,
        &snap_bundle_out.snap,
        resolution_out.actual,
        svc,
        events_enabled,
    );
}

/// `--vs <DATE>` entry point: compare two projections side-by-side
/// with deltas. The "then" side is always a historical snapshot at
/// `vs_date`; the "now" side is either another historical snapshot
/// (when `now_from_snapshot` is true) or the live portfolio at
/// `now_date`.
///
/// Target audience is the weekly review email's header — the
/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns.
/// For the full benchmark table / SWR grid / percentile bands, run
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
pub fn runCompare(
    ctx: *framework.RunCtx,
    file_path: []const u8,
    opts: KeyComparisonOptions,
) !void {
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const svc = ctx.svc orelse return error.MissingDataService;

    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();
    const va = arena_state.allocator();

    const result = computeKeyComparison(io, allocator, va, svc, file_path, opts) catch |err| switch (err) {
        error.NoSnapshot, error.PortfolioLoadFailed => return,
        else => return err,
    };
    defer result.cleanup();

    try out.print("\n", .{});
    var then_buf: [10]u8 = undefined;
    var now_buf: [10]u8 = undefined;
    const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{result.resolution.actual}) catch "????-??-??";
    const now_str = if (result.now_resolution) |nr| (std.fmt.bufPrint(&now_buf, "{f}", .{nr.actual}) catch "????-??-??") else "today";
    const days_between = if (result.now_resolution) |nr|
        nr.actual.days - result.resolution.actual.days
    else
        opts.now_date.days - result.resolution.actual.days;

    try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{
        then_str,
        now_str,
        days_between,
        if (days_between == 1) "" else "s",
    });

    // Snap notes for either endpoint, if applicable.
    if (result.resolution.actual.days != result.resolution.requested.days) {
        const diff = result.resolution.requested.days - result.resolution.actual.days;
        try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
            result.resolution.requested,
            then_str,
            diff,
            if (diff == 1) "" else "s",
        });
    }
    if (result.now_resolution) |nr| {
        if (nr.actual.days != nr.requested.days) {
            const diff = nr.requested.days - nr.actual.days;
            try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
                nr.requested,
                now_str,
                diff,
                if (diff == 1) "" else "s",
            });
        }
    }
    try out.print("\n", .{});

    try renderKeyComparisonRows(out, color, result.then, result.now, result.events_enabled);

    try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
        then_str,
        if (result.now_resolution) |_| (try std.fmt.allocPrint(va, " --as-of {s}", .{now_str})) else "",
    });
    try out.print("\n", .{});
}

// ── Forecast-vs-actual evaluation views ──────────────────────

/// Horizons used by `--return-backtest`. 1y/3y/5y per the
/// V1 spec; 10y is a polish-pass addition.
const backtest_horizons: []const u16 = &.{ 1, 3, 5 };

/// `zfin projections --convergence` entry point. Renders a
/// summary table of `(observation_date, projected_date,
/// years_until)` from the imported spreadsheet history. The CLI
/// is intentionally table-based — the high-fidelity chart lives
/// on the TUI projections tab.
///
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
/// loaded via `data.imported_values.loadImportedValues`. Missing
/// file is treated as "no historical data," not an error: the
/// command emits a one-line note and returns successfully.
///
/// Caveat (per spec): this view shows whether the model was
/// directionally honest about retirement timing. It does NOT
/// validate the SWR claim itself — that's a 30-year claim we
/// can't validate within either of our lifetimes.
pub fn runConvergence(
    io: std.Io,
    allocator: std.mem.Allocator,
    file_path: []const u8,
    color: bool,
    out: *std.Io.Writer,
) !void {
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();
    const va = arena_state.allocator();

    const hist_dir = try history.deriveHistoryDir(va, file_path);
    const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });

    var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
        cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
        return err;
    };
    defer iv.deinit();

    const points = try forecast.convergencePoints(va, iv.points);
    const lines = try view.convergenceLines(va, points);
    try renderForecastLines(out, color, lines);
}

/// `zfin projections --return-backtest [--real]` entry point.
/// Renders a summary table comparing the spreadsheet's
/// `expected_return` claim to realized 1y/3y/5y forward CAGR
/// for each anchor row.
///
/// When `real_mode` is true, the realized CAGR is computed
/// against inflation-deflated `liquid` values (Shiller annual
/// CPI). The expected_return column is left as-is (it's a return
/// rate, not a level — but it's a nominal return as captured by
/// the source spreadsheet, which means real-mode is comparing
/// nominal-claim against real-realized; useful but watch the
/// caveat in the output).
pub fn runReturnBacktest(
    io: std.Io,
    allocator: std.mem.Allocator,
    file_path: []const u8,
    real_mode: bool,
    color: bool,
    out: *std.Io.Writer,
) !void {
    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();
    const va = arena_state.allocator();

    const hist_dir = try history.deriveHistoryDir(va, file_path);
    const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });

    var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
        cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
        return err;
    };
    defer iv.deinit();

    // Build the YearCpi slice from Shiller's annual_returns. Same
    // shape `milestones.deflate` accepts.
    var cpi_list: std.ArrayList(milestones.YearCpi) = .empty;
    defer cpi_list.deinit(va);
    for (shiller.annual_returns) |yr| {
        try cpi_list.append(va, .{ .year = yr.year, .cpi = yr.cpi_inflation });
    }

    const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
    const anchors = try forecast.pivotByAnchor(va, rows);
    const lines = try view.backtestLines(va, anchors, real_mode);
    try renderForecastLines(out, color, lines);
}

/// Emit `view.ForecastLine`s through the CLI's ANSI styling
/// helpers. Shared by `runConvergence` and `runReturnBacktest` so
/// the bold/intent → ANSI mapping lives in exactly one place.
fn renderForecastLines(
    out: *std.Io.Writer,
    color: bool,
    lines: []const view.ForecastLine,
) !void {
    for (lines) |ln| {
        if (ln.bold) try cli.setBold(out, color);
        try cli.setStyleIntent(out, color, ln.intent);
        try out.print("{s}\n", .{ln.text});
        try cli.reset(out, color);
    }
    try out.print("\n", .{});
}

/// Shared key-metrics comparison used by both `projections --vs` and
/// `compare --projections`. Returns `then`/`now` metrics ready for
/// rendering, plus the snapshot resolutions for header rendering.
/// Caller must invoke `cleanup()` to release retained snapshots.
///
/// When `now_from_snapshot` is false (live mode), only `retained_then`
/// is populated. When true, both snapshots are retained and must be
/// cleaned up via `cleanup()`.
pub const KeyComparisonResult = struct {
    then: KeyMetrics,
    now: KeyMetrics,
    /// Resolution of the "then" snapshot. Always present.
    resolution: AsOfResolution,
    /// Resolution of the "now" snapshot. Null when now is live.
    now_resolution: ?AsOfResolution,
    /// Whether simulated lifecycle events (RMDs, lump-sum
    /// withdrawals, Social Security) were included in the
    /// projection. Captured here so the comparison-row caption
    /// can tell the reader what assumptions are baked in.
    events_enabled: bool,
    retained_then: history.LoadedSnapshot,
    retained_now: ?history.LoadedSnapshot,
    retained_allocator: std.mem.Allocator,

    pub fn cleanup(self: KeyComparisonResult) void {
        var mut = self;
        mut.retained_then.deinit(self.retained_allocator);
        if (mut.retained_now) |*s| s.deinit(self.retained_allocator);
    }
};

/// Per-call configuration for `computeKeyComparison`. Bundled into
/// a struct because the call already had eight context-plus-config
/// parameters and adding more knobs would push it past the readable
/// positional threshold.
pub const KeyComparisonOptions = struct {
    /// Whether simulated lifecycle events (RMDs, lump-sum
    /// withdrawals, Social Security) are baked into the
    /// projection. The "then" and "now" sides both honor this.
    events_enabled: bool,
    /// The earlier date — historical snapshot resolution.
    vs_date: Date,
    /// The later date — either live or another snapshot,
    /// controlled by `now_from_snapshot`.
    now_date: Date,
    /// When true, both sides resolve from snapshots. When
    /// false, the "now" side uses the live portfolio supplied
    /// via `live_for_now`.
    now_from_snapshot: bool,
    /// Pre-loaded live-portfolio data for the "now" side.
    /// REQUIRED when `now_from_snapshot == false`; ignored
    /// otherwise. The caller (typically `run` or
    /// `commands/compare.zig`'s `run`) loads this via
    /// `loadLiveData(ctx, ...)` so the multi-file union-merge
    /// path is always taken. See `LiveData`'s doc-comment for
    /// why the load lives in the caller and not here.
    live_for_now: ?*const LiveData = null,
};

/// Compute the "then" vs "now" key metrics for `--vs` and the
/// `compare --projections` embedded block.
///
/// Output-equivalence invariant: this function MUST produce
/// identical `KeyMetrics` to what standalone `projections` /
/// `projections --as-of` produce for the same dates. Both paths
/// resolve the same way:
///
///   - `then` (snapshot): `loadAsOfContext` →
///     `view.loadProjectionContextAsOf(...)` is the same call
///     standalone `projections --as-of` makes at line ~110.
///   - `now` (live): the `cli.loadPortfolio` →
///     `cli.buildPortfolioData` → `view.loadProjectionContext`
///     pipeline below mirrors standalone `projections` (no flags)
///     at lines ~167-202.
///
/// If you change inputs to either of these loaders, change them
/// in BOTH places. A drift between this path and standalone
/// projections will show as `compare --projections` reporting
/// different numbers from what `projections --as-of <DATE>` and
/// `projections` print, which has bitten us before.
pub fn computeKeyComparison(
    io: std.Io,
    allocator: std.mem.Allocator,
    va: std.mem.Allocator,
    svc: *zfin.DataService,
    file_path: []const u8,
    opts: KeyComparisonOptions,
) !KeyComparisonResult {
    const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
    const portfolio_dir = file_path[0..dir_end];

    // Load "then" snapshot first. If it doesn't exist we bail before
    // doing the (more expensive) "now" side.
    // SAFETY: out-param populated by `loadAsOfContext` on success;
    // on error we return before any read.
    var then_resolution: AsOfResolution = undefined;
    // SAFETY: same out-param pattern as `then_resolution`.
    var then_snap: history.LoadedSnapshot = undefined;
    const then_ctx = try loadAsOfContext(
        io,
        allocator,
        va,
        svc,
        file_path,
        portfolio_dir,
        opts.events_enabled,
        opts.vs_date,
        &then_resolution,
        &then_snap,
    );

    // Now side — either another snapshot or the live portfolio.
    if (opts.now_from_snapshot) {
        // SAFETY: out-param populated by `loadAsOfContext`.
        var now_resolution: AsOfResolution = undefined;
        // SAFETY: out-param populated by `loadAsOfContext`.
        var now_snap: history.LoadedSnapshot = undefined;
        const now_ctx = loadAsOfContext(
            io,
            allocator,
            va,
            svc,
            file_path,
            portfolio_dir,
            opts.events_enabled,
            opts.now_date,
            &now_resolution,
            &now_snap,
        ) catch |err| {
            then_snap.deinit(allocator);
            return err;
        };

        return .{
            .then = extractKeyMetrics(then_ctx),
            .now = extractKeyMetrics(now_ctx),
            .resolution = then_resolution,
            .now_resolution = now_resolution,
            .events_enabled = opts.events_enabled,
            .retained_then = then_snap,
            .retained_now = now_snap,
            .retained_allocator = allocator,
        };
    }

    // Live "now" side. The caller pre-loads via `loadLiveData` and
    // passes the result in `opts.live_for_now`.
    const live = opts.live_for_now orelse {
        then_snap.deinit(allocator);
        cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live_for_now`.\n");
        return error.PortfolioLoadFailed;
    };

    const now_ctx = try view.loadProjectionContext(
        io,
        va,
        portfolio_dir,
        live.pf_data.summary.allocations,
        live.pf_data.summary.total_value,
        live.loaded.portfolio.totalCash(opts.now_date),
        live.loaded.portfolio.totalCdFaceValue(opts.now_date),
        svc,
        opts.events_enabled,
        opts.now_date,
    );

    return .{
        .then = extractKeyMetrics(then_ctx),
        .now = extractKeyMetrics(now_ctx),
        .resolution = then_resolution,
        .now_resolution = null,
        .events_enabled = opts.events_enabled,
        .retained_then = then_snap,
        .retained_now = null,
        .retained_allocator = allocator,
    };
}

/// Render the three comparison rows (projected return, SWR @99%, SWR
/// rate). Shared between `projections --vs` and any other caller that
/// wants to embed the same block (e.g. `compare --projections`).
/// Render the three "then → now" comparison rows (projected return,
/// SWR @99% dollars, SWR @99% rate) for the `--vs` and
/// `compare --projections` outputs.
///
/// The leading caption ("{N}-year horizon, lifecycle events
/// {included|excluded}") tells the reader which assumptions are
/// baked in: lots of numbers crowd the report, and "Safe
/// withdrawal @99%" alone is ambiguous about which horizon's SWR
/// is being shown (the TUI's full table renders both 30-year and
/// 43-year columns, but this compact view only shows the longest
/// horizon). Surface the context so a reader scanning the
/// numbers doesn't have to remember.
pub fn renderKeyComparisonRows(
    out: *std.Io.Writer,
    color: bool,
    then: KeyMetrics,
    now: KeyMetrics,
    events_enabled: bool,
) !void {
    // `then` and `now` are computed against the same projections.srf
    // (REPORT.md §4 — the "then" side reuses today's config), so
    // their horizons agree. Use whichever side is convenient.
    const events_label: []const u8 = if (events_enabled) "included" else "excluded";
    try cli.printFg(out, color, cli.CLR_MUTED, "  ({d}-year horizon, lifecycle events {s})\n", .{ now.horizon_years, events_label });

    try renderCompareRowPct(out, color, "Projected return:", then.projected_return, now.projected_return);
    try renderCompareRowMoney(out, color, "Safe withdrawal @99%:", then.swr_99, now.swr_99);
    try renderCompareRowPct(out, color, "  (as % of total)", then.swr_99_rate, now.swr_99_rate);
}

/// Render a "label: then → now    Δ" row for percentage values.
fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
    const delta = now_val - then_val;
    var then_buf: [16]u8 = undefined;
    var now_buf: [16]u8 = undefined;
    var delta_buf: [16]u8 = undefined;
    const then_str = std.fmt.bufPrint(&then_buf, "{d:.2}%", .{then_val * 100.0}) catch "?";
    const now_str = std.fmt.bufPrint(&now_buf, "{d:.2}%", .{now_val * 100.0}) catch "?";
    const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?";

    try cli.printFg(out, color, cli.CLR_MUTED, "  {s:<22}  ", .{label});
    try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10}    ", .{ then_str, now_str });
    try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str});
}

/// Render a "label: then → now    Δ" row for money values.
fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
    const delta = now_val - then_val;

    try cli.printFg(out, color, cli.CLR_MUTED, "  {s:<22}  ", .{label});
    try cli.printFg(out, color, cli.CLR_MUTED, "{f} → {f}    ", .{
        Money.from(then_val).padRight(10),
        Money.from(now_val).padRight(10),
    });
    try cli.printGainLoss(out, color, delta, "{f}\n", .{Money.from(delta).signed().padRight(12)});
}

/// Resolve the user's requested as-of date against the history
/// directory, accepting either a native snapshot or an
/// `imported_values.srf` row.
///
/// Thin adapter over `cli.resolveAsOfOrExplain` — the shared CLI
/// helper owns the exact-then-fallback resolution and the stderr
/// messaging. This wrapper just maps the error set to
/// `error.NoSnapshot` (projections-specific) and packs the source +
/// liquid fields into the local `AsOfResolution` shape.
///
/// Arena-allocates the intermediate `hist_dir` + filename strings;
/// pass a short-lived arena as `va`.
fn resolveAsOfSnapshot(
    io: std.Io,
    va: std.mem.Allocator,
    file_path: []const u8,
    requested: Date,
) !AsOfResolution {
    const hist_dir = try history.deriveHistoryDir(va, file_path);

    const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) {
        error.NoDataAtOrBefore => return error.NoSnapshot,
        else => |e| {
            cli.stderrPrint(io, "Error resolving as-of: ");
            cli.stderrPrint(io, @errorName(e));
            cli.stderrPrint(io, "\n");
            return error.NoSnapshot;
        },
    };

    return .{
        .requested = resolved.requested,
        .actual = resolved.actual,
        .source = resolved.source,
        .liquid = resolved.liquid,
    };
}

/// Load the merged history timeline (snapshots + imported_values),
/// filter to the [`as_of`, `today`] range, and produce an overlay
/// section. Caller passes an arena allocator so all intermediate
/// allocations are freed at the end of the request.
///
/// Returns null on a missing/empty history dir — that's a soft
/// failure (no overlay rendered, projection still works).
fn loadOverlayActuals(
    io: std.Io,
    arena: std.mem.Allocator,
    file_path: []const u8,
    as_of: Date,
    today: Date,
) !?view.OverlayActualsSection {
    var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) {
        // Missing/unreadable history dir → no overlay, no error.
        error.FileNotFound, error.NotDir, error.AccessDenied => return null,
        else => return err,
    };
    defer loaded.deinit();

    if (loaded.series.points.len == 0) return null;

    return try view.buildOverlayActuals(arena, loaded.series.points, as_of, today);
}

/// Write a return row using the view model, applying StyleIntent colors.
fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void {
    try out.print("{s: <32}", .{row.label});
    try writeCell(out, color, row.one_year, 8);
    try writeCell(out, color, row.three_year, 9);
    try writeCell(out, color, row.five_year, 9);
    try writeCell(out, color, row.ten_year, 10);
    try writeCell(out, color, row.week, 9);
    try out.print("\n", .{});
}

fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void {
    switch (width) {
        8 => try cli.printIntent(out, color, cell.style, "{s: >8}", .{cell.text}),
        9 => try cli.printIntent(out, color, cell.style, "{s: >9}", .{cell.text}),
        10 => try cli.printIntent(out, color, cell.style, "{s: >10}", .{cell.text}),
        else => try cli.printIntent(out, color, cell.style, "{s}", .{cell.text}),
    }
}

/// Render the "Accumulation phase" block (driven by the user's
/// target retirement date — `retirement_age` / `retirement_at` —
/// or by the promoted cell from the earliest-retirement search when
/// only `target_spending` is configured).
///
/// Always emits the "Years until possible retirement" line — including
/// `none` for the already-retired case, where the entire block reduces
/// to that single line. When a retirement date is configured, the
/// median portfolio at retirement and the p10–p90 range follow,
/// computed from the longest-horizon percentile bands.
fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void {
    try out.print("\n", .{});
    try cli.printBold(out, color, "Accumulation phase:\n", .{});

    var line_buf: [128]u8 = undefined;
    const parts = view.splitRetirementLine(&line_buf, ctx.retirement, &ctx.config);
    // Label stays neutral; only the value (date / "not feasible" /
    // "none") carries a style. This keeps "not feasible" loud
    // without making the entire row scream red.
    try out.print("  {s}", .{parts.label_text});
    try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text});

    // Contribution line — suppressed when both contribution and
    // accumulation are zero.
    if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| {
        try out.print("  {s}\n", .{contrib});
    }

    // Accumulation-phase stats: median + p10-p90 range at the
    // retirement boundary.
    if (ctx.accumulation) |acc| {
        try out.print("  Median portfolio at retirement:  {f}\n", .{Money.from(acc.median_at_retirement).trim()});
        try out.print("  Range (10th\u{2013}90th percentile):    {f} to {f}\n", .{
            Money.from(acc.p10_at_retirement).trim(),
            Money.from(acc.p90_at_retirement).trim(),
        });
    }
}

/// Render the "Earliest retirement" block (driven by the user's
/// target spending — `target_spending`).
///
/// Renders a grid of (confidence × horizon) cells, each showing the
/// earliest retirement date that sustains the target spending at that
/// confidence over that distribution horizon, or "—" when not feasible
/// within `max_accumulation_years`.
fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext, as_of: zfin.Date) !void {
    const earliest = ctx.earliest orelse return;
    const target = ctx.config.target_spending orelse return;

    try out.print("\n", .{});
    const adj: []const u8 = if (ctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal";
    try cli.printBold(out, color, "Earliest retirement (target spending: {f}/yr {s})\n", .{ Money.from(target).trim(), adj });

    const horizons = ctx.config.getHorizons();
    const confs = ctx.config.getConfidenceLevels();

    // Header row: blank label space + horizon column headers.
    const cell_width: usize = 14;
    const label_width: usize = 25;
    {
        var hdr: std.ArrayListUnmanaged(u8) = .empty;
        try hdr.appendNTimes(va, ' ', label_width);
        for (horizons) |h| {
            var hbuf: [16]u8 = undefined;
            const hlabel = view.fmtHorizonLabel(&hbuf, h);
            try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len);
            try hdr.appendSlice(va, hlabel);
        }
        try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{hdr.items});
    }

    for (confs, 0..) |conf, ci| {
        const row = try view.buildEarliestRow(va, conf, horizons, earliest, ci, as_of);
        // Label
        try out.print("  {s}", .{row.label_text});
        // Pad label to label_width (we wrote 2 leading spaces + label, so pad to label_width - 2).
        const pad = if (label_width > 2 + row.label_text.len) label_width - 2 - row.label_text.len else 0;
        for (0..pad) |_| try out.print(" ", .{});
        // Cells
        for (row.cells) |cell| {
            const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0;
            for (0..cellpad) |_| try out.print(" ", .{});
            try cli.printIntent(out, color, cell.style, "{s}", .{cell.text});
        }
        try out.print("\n", .{});
    }
}

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

const testing = std.testing;

fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
    var ctx: framework.RunCtx = .{
        .io = std.testing.io,
        .allocator = std.testing.allocator,
        .gpa = std.testing.allocator,
        // SAFETY: parseArgs doesn't touch environ_map.
        .environ_map = undefined,
        .config = .{ .cache_dir = "" },
        .svc = null,
        .globals = .{},
        .today = today,
        .now_s = 0,
        .color = false,
        // SAFETY: parseArgs doesn't write to out.
        .out = undefined,
    };
    return parseArgs(&ctx, args);
}

test "parseArgs: empty → bands variant with defaults" {
    const today = Date.fromYmd(2026, 5, 9);
    const parsed = try parseArgsForTest(today, &.{});
    switch (parsed) {
        .bands => |b| {
            try testing.expect(b.events_enabled);
            try testing.expect(b.as_of == null);
            try testing.expect(!b.overlay_actuals);
        },
        else => try testing.expect(false),
    }
}

test "parseArgs: --as-of populates bands.as_of" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--as-of", "2026-04-01" };
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .bands => |b| try testing.expect(b.as_of.?.eql(Date.fromYmd(2026, 4, 1))),
        else => try testing.expect(false),
    }
}

test "parseArgs: --no-events disables events on bands" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{"--no-events"};
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .bands => |b| try testing.expect(!b.events_enabled),
        else => try testing.expect(false),
    }
}

test "parseArgs: --vs produces compare variant" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--vs", "2026-04-01" };
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .compare => |c| try testing.expect(c.vs_date.eql(Date.fromYmd(2026, 4, 1))),
        else => try testing.expect(false),
    }
}

test "parseArgs: --vs + --as-of carries both into compare variant" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--vs", "2026-03-01", "--as-of", "2026-04-01" };
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .compare => |c| {
            try testing.expect(c.vs_date.eql(Date.fromYmd(2026, 3, 1)));
            try testing.expect(c.as_of.?.eql(Date.fromYmd(2026, 4, 1)));
        },
        else => try testing.expect(false),
    }
}

test "parseArgs: --convergence produces convergence variant" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{"--convergence"};
    const parsed = try parseArgsForTest(today, &args);
    try testing.expectEqual(std.meta.Tag(ParsedArgs).convergence, std.meta.activeTag(parsed));
}

test "parseArgs: --return-backtest produces return_backtest variant" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{"--return-backtest"};
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .return_backtest => |rb| try testing.expect(!rb.real),
        else => try testing.expect(false),
    }
}

test "parseArgs: --return-backtest --real" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--return-backtest", "--real" };
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .return_backtest => |rb| try testing.expect(rb.real),
        else => try testing.expect(false),
    }
}

test "parseArgs: --convergence + --return-backtest mutually exclusive" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--convergence", "--return-backtest" };
    try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}

test "parseArgs: --convergence + --vs rejected" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--convergence", "--vs", "2026-04-01" };
    try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}

test "parseArgs: --real without --return-backtest rejected" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{"--real"};
    try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}

test "parseArgs: future --as-of rejected" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--as-of", "2027-01-01" };
    try testing.expectError(error.InvalidFlagValue, parseArgsForTest(today, &args));
}

test "parseArgs: unknown flag errors" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{"--bogus"};
    try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}

test "parseArgs: --export-chart without a value is rejected" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{"--export-chart"};
    try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
}

test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--export-chart", "--real" };
    try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
}

test "parseArgs: --overlay-actuals carries into bands" {
    const today = Date.fromYmd(2026, 5, 9);
    const args = [_][]const u8{ "--as-of", "2026-04-01", "--overlay-actuals" };
    const parsed = try parseArgsForTest(today, &args);
    switch (parsed) {
        .bands => |b| try testing.expect(b.overlay_actuals),
        else => try testing.expect(false),
    }
}

const snapshot_model = @import("../models/snapshot.zig");
const snapshot = @import("snapshot.zig");

fn makeTestSvc() zfin.DataService {
    const config = zfin.Config{ .cache_dir = "/tmp" };
    return zfin.DataService.init(std.testing.io, testing.allocator, config);
}

fn writeFixtureSnapshot(
    io: std.Io,
    dir: std.Io.Dir,
    allocator: std.mem.Allocator,
    filename: []const u8,
    as_of: Date,
    liquid: f64,
) !void {
    const lots = [_]snapshot_model.LotRow{
        .{
            .kind = "lot",
            .symbol = "VTI",
            .lot_symbol = "VTI",
            .account = "Roth",
            .security_type = "Stock",
            .shares = 100,
            .open_price = 200,
            .cost_basis = 20_000,
            .value = liquid,
            .price = liquid / 100,
            .quote_date = as_of,
        },
    };
    const totals = [_]snapshot_model.TotalRow{
        .{ .kind = "total", .scope = "net_worth", .value = liquid },
        .{ .kind = "total", .scope = "liquid", .value = liquid },
        .{ .kind = "total", .scope = "illiquid", .value = 0 },
    };
    const snap: snapshot_model.Snapshot = .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = as_of,
            .captured_at = 1_745_222_400,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = @constCast(&totals),
        .tax_types = &.{},
        .accounts = &.{},
        .lots = @constCast(&lots),
    };
    const rendered = try snapshot.renderSnapshot(allocator, snap);
    defer allocator.free(rendered);
    try dir.writeFile(io, .{ .sub_path = filename, .data = rendered });
}

/// Build a portfolio path inside `tmp` and return the joined string.
/// Caller owns the returned buffer.
fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 {
    const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
    defer allocator.free(dir_path);
    return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
}

test "resolveAsOfSnapshot: exact match returns actual == requested" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDirPath(io, "history");
    var hist_dir = try tmp.dir.openDir(io, "history", .{});
    defer hist_dir.close(io);

    const d = Date.fromYmd(2026, 3, 13);
    try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000);

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

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

    const res = try resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, d);
    try testing.expect(res.actual.eql(d));
    try testing.expect(res.requested.eql(d));
}

test "resolveAsOfSnapshot: no exact match snaps to earlier" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDirPath(io, "history");
    var hist_dir = try tmp.dir.openDir(io, "history", .{});
    defer hist_dir.close(io);

    const earlier = Date.fromYmd(2026, 3, 12);
    try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-12-portfolio.srf", earlier, 1_000_000);

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

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

    const requested = Date.fromYmd(2026, 3, 13);
    const res = try resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested);
    try testing.expect(res.actual.eql(earlier));
    try testing.expect(res.requested.eql(requested));
    try testing.expect(!res.actual.eql(res.requested));
}

test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDirPath(io, "history");
    var hist_dir = try tmp.dir.openDir(io, "history", .{});
    defer hist_dir.close(io);

    // Only a later snapshot exists — can't satisfy an earlier request.
    const later = Date.fromYmd(2026, 4, 1);
    try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

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

    const requested = Date.fromYmd(2026, 3, 13);
    const result = resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested);
    try testing.expectError(error.NoSnapshot, result);
}

test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.createDirPath(io, "history");

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

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

    const requested = Date.fromYmd(2026, 3, 13);
    const result = resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested);
    try testing.expectError(error.NoSnapshot, result);
}

test "run: as_of with no snapshots returns without error (stderr-only)" {
    const io = std.testing.io;
    // No history dir at all. `run` prints a stderr hint via
    // `resolveAsOfSnapshot` and returns — should NOT propagate the
    // error to the caller (exit code stays 0 from the CLI dispatch).
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var svc = makeTestSvc();
    defer svc.deinit();

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

    var buf: [4096]u8 = undefined;
    var stream = std.Io.Writer.fixed(&buf);

    const d = Date.fromYmd(2026, 3, 13);
    try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);

    // No body output because the resolution failed — the stderr
    // message is swallowed by `cli.stderrPrint` and doesn't land in
    // `stream`. This guarantees the error-path returns cleanly.
    const out = stream.buffered();
    try testing.expectEqual(@as(usize, 0), out.len);
}

test "run: as_of with matching snapshot produces body output" {
    const io = std.testing.io;
    // End-to-end smoke test. With no cached candles, benchmark rows
    // will be `--` and portfolio returns will be empty, but the
    // rendering pipeline should still produce a complete header +
    // tables without panicking.
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var svc = makeTestSvc();
    defer svc.deinit();

    try tmp.dir.createDirPath(io, "history");
    var hist_dir = try tmp.dir.openDir(io, "history", .{});
    defer hist_dir.close(io);

    const d = Date.fromYmd(2026, 3, 13);
    try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000);

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

    var buf: [32_768]u8 = undefined;
    var stream = std.Io.Writer.fixed(&buf);
    try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);

    const out = stream.buffered();
    // Header should call out the as-of date explicitly.
    try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-13") != null);
    // Benchmark + withdrawal tables still render even with missing candles.
    try testing.expect(std.mem.indexOf(u8, out, "Safe Withdrawal") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Terminal Portfolio Value") != null);
}

test "run: as_of auto-snap surfaces muted 'nearest' note" {
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var svc = makeTestSvc();
    defer svc.deinit();

    try tmp.dir.createDirPath(io, "history");
    var hist_dir = try tmp.dir.openDir(io, "history", .{});
    defer hist_dir.close(io);

    const actual = Date.fromYmd(2026, 3, 12);
    try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-12-portfolio.srf", actual, 1_000_000);

    const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
    defer testing.allocator.free(pf);

    var buf: [32_768]u8 = undefined;
    var stream = std.Io.Writer.fixed(&buf);

    const requested = Date.fromYmd(2026, 3, 13);
    try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream);

    const out = stream.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
    try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null);
    try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null);
    // 1 day earlier → singular "day", not "days"
    try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
}

test "renderCompareRowPct: positive delta renders with + sign" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderCompareRowPct(&w, false, "Stocks", 0.50, 0.65);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Stocks") != null);
    try testing.expect(std.mem.indexOf(u8, out, "50.00%") != null);
    try testing.expect(std.mem.indexOf(u8, out, "65.00%") != null);
    try testing.expect(std.mem.indexOf(u8, out, "+15.00%") != null);
}

test "renderCompareRowPct: negative delta has no + sign" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderCompareRowPct(&w, false, "Bonds", 0.40, 0.30);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "40.00%") != null);
    try testing.expect(std.mem.indexOf(u8, out, "30.00%") != null);
    try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null);
}

test "renderCompareRowMoney: positive delta" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderCompareRowMoney(&w, false, "Net Worth", 100_000, 110_000);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null);
    try testing.expect(std.mem.indexOf(u8, out, "$100,000") != null);
    try testing.expect(std.mem.indexOf(u8, out, "$110,000") != null);
}

test "renderCompareRowMoney: zero delta" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderCompareRowMoney(&w, false, "Cash", 50_000, 50_000);
    const out = w.buffered();
    try testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
    try testing.expect(std.mem.indexOf(u8, out, "$50,000") != null);
}

test "renderCompareRowPct: no ANSI when color=false" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderCompareRowPct(&w, false, "X", 0.1, 0.2);
    try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}
