/// Renderer-agnostic view model for the projections display.
///
/// Produces pre-formatted text and `StyleIntent` values that both CLI
/// and TUI renderers can consume through thin style-mapping adapters.
const std = @import("std");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const performance = @import("../analytics/performance.zig");
const benchmark = @import("../analytics/benchmark.zig");
const projections = @import("../analytics/projections.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const timeline = @import("../analytics/timeline.zig");
const valuation = @import("../analytics/valuation.zig");
const zfin = @import("../root.zig");
const snapshot_model = @import("../models/snapshot.zig");
const history = @import("../history.zig");
const Date = @import("../Date.zig");

pub const StyleIntent = fmt.StyleIntent;

// ── Layout constants (shared by CLI and TUI) ──────────────────

pub const label_width = 32;
pub const col_1y = 8;
pub const col_3y = 9;
pub const col_5y = 9;
pub const col_10y = 10;
pub const col_week = 9;
pub const withdrawal_label_width = 25;
pub const withdrawal_col_width = 12;
pub const terminal_col_width = 18;

// ── Return row formatting ──────────────────────────────────────

/// A single cell in the returns table: formatted text + style.
pub const ReturnCell = struct {
    text: []const u8,
    style: StyleIntent,
};

/// Format a return value into a buffer, returning the styled cell.
pub fn fmtReturnCell(buf: []u8, value: ?f64) ReturnCell {
    if (value) |v| {
        return .{
            .text = performance.formatReturn(buf, v),
            .style = if (v >= 0) .positive else .negative,
        };
    }
    return .{ .text = "--", .style = .muted };
}

/// A complete row in the benchmark comparison table.
pub const ReturnRow = struct {
    label: []const u8,
    one_year: ReturnCell,
    three_year: ReturnCell,
    five_year: ReturnCell,
    ten_year: ReturnCell,
    week: ReturnCell,
    bold: bool = false,
};

/// Build a return row from a ReturnsByPeriod and a label.
pub fn buildReturnRow(label: []const u8, returns: benchmark.ReturnsByPeriod, bufs: *[5][16]u8, bold: bool) ReturnRow {
    return .{
        .label = label,
        .one_year = fmtReturnCell(&bufs[0], returns.one_year),
        .three_year = fmtReturnCell(&bufs[1], returns.three_year),
        .five_year = fmtReturnCell(&bufs[2], returns.five_year),
        .ten_year = fmtReturnCell(&bufs[3], returns.ten_year),
        .week = fmtReturnCell(&bufs[4], returns.week),
        .bold = bold,
    };
}

// ── Safe withdrawal formatting ─────────────────────────────────

/// A single cell in the withdrawal table.
pub const WithdrawalCell = struct {
    amount_text: []const u8,
    rate_text: []const u8,
};

/// Format a safe withdrawal result into display strings.
/// Caller owns both buffers (at least 24 bytes each).
/// Strips trailing ".00" from whole-dollar amounts for clean display.
pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell {
    const clean_amount = std.fmt.bufPrint(amount_buf, "{f}", .{Money.from(result.annual_amount).trim()}) catch "$?";
    const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%";
    return .{ .amount_text = clean_amount, .rate_text = rate_str };
}

/// Format a confidence level label (e.g. "99% safe withdrawal").
pub fn fmtConfidenceLabel(buf: []u8, confidence: f64) []const u8 {
    return std.fmt.bufPrint(buf, "{d:.0}% safe withdrawal", .{confidence * 100}) catch "??";
}

/// Format a horizon column header (e.g. "30 Year").
pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 {
    return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??";
}

// ── Allocation summary ─────────────────────────────────────────

/// Result of formatting the allocation note.
pub const AllocationNote = struct {
    text: []const u8,
    style: StyleIntent,
};

/// Format the target allocation note line with drift-aware styling.
/// Returns null if no target is configured.
///
/// Drift thresholds:
///   - Within 5%: "on target" (muted)
///   - 5–10% off: warning
///   - Over 10% off: negative
pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote {
    const target = target_stock_pct orelse return null;
    const current = current_stock_pct * 100;
    const drift = @abs(current - target);
    const style: StyleIntent = if (drift < 5.0) .muted else if (drift < 10.0) .warning else .negative;
    const text = if (drift < 5.0)
        std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds  (current: {d:.1}% \u{2014} on target)", .{
            target, 100.0 - target, current,
        }) catch return null
    else
        std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds  (current: {d:.1}%)", .{
            target, 100.0 - target, current,
        }) catch return null;
    return .{ .text = text, .style = style };
}

/// Format the stock benchmark label with weight.
pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 {
    return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol;
}

// ── Precomputed projection data (shared by CLI and TUI) ────────

pub const ProjectionContext = struct {
    comparison: benchmark.BenchmarkComparison,
    config: projections.UserConfig,
    data: ProjectionData,
    stock_pct: f64,
    bond_pct: f64,
    total_value: f64,
    /// Resolved retirement boundary against the projection's reference
    /// date (today for live mode, `as_of` for historical mode).
    retirement: projections.ResolvedRetirement = .{ .accumulation_years = 0, .date = null, .source = .none },
    /// Statistics from the simulation's accumulation phase: portfolio
    /// value at the retirement boundary, computed from the configured
    /// median/p10/p90 percentile bands. `null` when the user has not
    /// configured any retirement date (so the simulation runs
    /// distribution-only and there's no boundary year to evaluate at).
    accumulation: ?AccumulationStats = null,
    /// "Earliest retirement" grid results: one entry per (horizon ×
    /// confidence) pair when the user configured `target_spending`,
    /// or `null` otherwise.
    earliest: ?[]projections.EarliestRetirement = null,
    /// Which retirement-planning inputs the user configured. Drives
    /// which display blocks render.
    inputs: ProjectionInputs = .distribution_only,
    /// Realized trajectory overlay (snapshots + imported_values).
    /// Populated only when the user passed `--overlay-actuals` (CLI)
    /// or toggled `o` in the TUI projections tab. Requires `as_of`
    /// to be set; meaningless in live mode.
    overlay_actuals: ?OverlayActualsSection = null,
    /// Where the as-of context came from. `.live` for normal "now"
    /// mode (no `--as-of`); `.snapshot` when the as-of date had a
    /// native `*-portfolio.srf` snapshot; `.imported` when only an
    /// `imported_values.srf` row was available and today's
    /// allocations had to be scaled to the imported liquid total.
    /// Drives the header note ("As-of: ... (snapshot)" vs "(imported)")
    /// so users know how literal the bands are.
    as_of_source: AsOfSource = .live,
};

pub const AsOfSource = enum { live, snapshot, imported };

/// Statistics extracted from the bands at the retirement-boundary
/// year. Used to render the median portfolio at retirement and the
/// p10–p90 range under the "Accumulation phase" display block.
pub const AccumulationStats = struct {
    median_at_retirement: f64,
    p10_at_retirement: f64,
    p90_at_retirement: f64,
    annual_contribution: f64,
    contribution_inflation_adjusted: bool,
};

/// One realized (date, total liquid) point on the actuals overlay,
/// expressed in years from the as-of date so the chart can place it
/// on the same x-axis as the projected percentile bands.
pub const ActualsPoint = struct {
    /// Years since `as_of` (as_of itself = 0.0). Uses 365.25 to
    /// match the rest of the codebase's year math (`Date.yearsBetween`).
    years_from_as_of: f64,
    /// Total liquid value on that date, in nominal dollars.
    liquid: f64,
};

/// View-model section produced when `--overlay-actuals` is active.
/// Carries the realized-trajectory points to render on top of the
/// percentile-band chart, plus the "today" position for the
/// vertical reference line.
///
/// Caveat (must be surfaced in any UI consuming this section):
/// this overlay shows whether the model was directionally honest,
/// not whether the SWR claim was accurate. The SWR claim is a
/// 30-year claim; we have at most ~12 years of weekly history.
pub const OverlayActualsSection = struct {
    points: []ActualsPoint,
    /// Years from `as_of` to today. The actuals line ends here;
    /// beyond this x-position only bands are visible.
    today_years: f64,
    /// The as-of date the overlay is anchored to; surfaced for
    /// status-line / footnote display ("actuals from YYYY-MM-DD").
    as_of: Date,
    allocator: std.mem.Allocator,

    pub fn deinit(self: *OverlayActualsSection) void {
        self.allocator.free(self.points);
    }
};

/// Build an `OverlayActualsSection` from a merged `TimelineSeries`
/// (snapshots + imported_values). Filters to `as_of..today`,
/// converts each point's date to fractional years from `as_of`, and
/// pulls the liquid total. Snapshot precedence is already handled
/// upstream by `timeline.buildMergedSeries`.
///
/// Returns an empty section (no error) when the timeline yields no
/// points in range — the caller still toggles the overlay on, the
/// chart simply has no actuals line to draw.
pub fn buildOverlayActuals(
    allocator: std.mem.Allocator,
    timeline_points: []const timeline.TimelinePoint,
    as_of: Date,
    today: Date,
) !OverlayActualsSection {
    const days_per_year: f64 = 365.25;

    // Count first so we can size the slice exactly.
    var keep: usize = 0;
    for (timeline_points) |p| {
        if (p.as_of_date.days < as_of.days) continue;
        if (p.as_of_date.days > today.days) continue;
        keep += 1;
    }

    const out = try allocator.alloc(ActualsPoint, keep);
    errdefer allocator.free(out);

    var i: usize = 0;
    for (timeline_points) |p| {
        if (p.as_of_date.days < as_of.days) continue;
        if (p.as_of_date.days > today.days) continue;
        const days_since: f64 = @floatFromInt(p.as_of_date.days - as_of.days);
        out[i] = .{
            .years_from_as_of = days_since / days_per_year,
            .liquid = p.liquid,
        };
        i += 1;
    }

    const today_days: f64 = @floatFromInt(today.days - as_of.days);
    return .{
        .points = out,
        .today_years = today_days / days_per_year,
        .as_of = as_of,
        .allocator = allocator,
    };
}

/// Which retirement-planning inputs the user has configured.
///
/// The simulation always runs the same two-phase model
/// (accumulation followed by distribution); these variants describe
/// only which output blocks the display layer renders.
///
///   - `.distribution_only` — neither a target retirement date
///     (`retirement_age` / `retirement_at`) nor a target spending
///     (`target_spending`) is set. Already-retired users; the
///     accumulation phase has zero years.
///   - `.target_retirement_date` — `retirement_age` or
///     `retirement_at` is set. The display reports the spending the
///     accumulated portfolio supports.
///   - `.target_spending` — `target_spending` is set. The display
///     reports the date(s) at which that spending becomes
///     sustainable, and promotes one cell from the resulting grid
///     into the headline retirement line.
///   - `.both_targets` — both are set. Both blocks render
///     back-to-back; the configured date wins for the headline.
pub const ProjectionInputs = enum {
    distribution_only,
    target_retirement_date,
    target_spending,
    both_targets,
};

pub const ProjectionData = projections.ProjectionData;

pub const runProjectionGrid = projections.runProjectionGrid;

pub fn buildProjectionContext(
    alloc: std.mem.Allocator,
    config: projections.UserConfig,
    comparison: benchmark.BenchmarkComparison,
    stock_pct: f64,
    bond_pct: f64,
    total_value: f64,
    events: []const projections.ResolvedEvent,
    as_of: Date,
) !ProjectionContext {
    const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct;
    // `expense_ratio` is stored as a percentage in UserConfig (like
    // target_stock_pct); the simulation wants a decimal.
    const sim_expense_ratio = config.expense_ratio / 100.0;

    // Resolve the retirement boundary from the user's target
    // retirement date (`retirement_age` / `retirement_at`). Returns
    // `.none` when neither is configured.
    var retirement = config.resolveRetirement(as_of);
    const accumulation_years: u16 = retirement.accumulation_years;

    const data = try runProjectionGrid(
        alloc,
        config.getHorizons(),
        config.getConfidenceLevels(),
        total_value,
        sim_stock_pct,
        events,
        accumulation_years,
        config.annual_contribution,
        config.contribution_inflation_adjusted,
        sim_expense_ratio,
    );

    // Accumulation-phase stats: extract portfolio value at the
    // retirement boundary from the longest-horizon band (most data
    // available; same boundary year for all horizons).
    var accumulation_stats: ?AccumulationStats = null;
    if (accumulation_years > 0) {
        const horizons = config.getHorizons();
        if (horizons.len > 0) {
            const last_band = data.bands[horizons.len - 1];
            if (last_band) |b| {
                if (b.len > @as(usize, accumulation_years)) {
                    const yp = b[@as(usize, accumulation_years)];
                    accumulation_stats = .{
                        .median_at_retirement = yp.p50,
                        .p10_at_retirement = yp.p10,
                        .p90_at_retirement = yp.p90,
                        .annual_contribution = config.annual_contribution,
                        .contribution_inflation_adjusted = config.contribution_inflation_adjusted,
                    };
                }
            }
        }
    }

    // Earliest retirement grid: when `target_spending` is set,
    // search for the earliest retirement year per (horizon ×
    // confidence) pair.
    var earliest: ?[]projections.EarliestRetirement = null;
    if (config.target_spending) |target| {
        const horizons = config.getHorizons();
        const confs = config.getConfidenceLevels();
        const cells = try alloc.alloc(projections.EarliestRetirement, horizons.len * confs.len);
        for (confs, 0..) |conf, ci| {
            for (horizons, 0..) |h, hi| {
                cells[ci * horizons.len + hi] = try projections.findEarliestRetirement(
                    alloc,
                    total_value,
                    sim_stock_pct,
                    config.annual_contribution,
                    config.contribution_inflation_adjusted,
                    target,
                    config.target_spending_inflation_adjusted,
                    h,
                    conf,
                    events,
                    projections.max_accumulation_years,
                    sim_expense_ratio,
                );
            }
        }
        earliest = cells;
    }

    const has_target_date = retirement.source != .none;
    const has_target_spend = config.target_spending != null;
    const inputs: ProjectionInputs = if (has_target_spend and has_target_date)
        .both_targets
    else if (has_target_spend)
        .target_spending
    else if (has_target_date)
        .target_retirement_date
    else
        .distribution_only;

    // Promotion: when target_spending is configured but no explicit
    // retirement date is, pick a cell from the Earliest retirement
    // grid and promote it into `retirement` + `accumulation_stats`.
    // This keeps the Accumulation phase block coherent with the
    // target-spending answer below it.
    if (inputs == .target_spending) {
        if (earliest) |grid| {
            const horizons = config.getHorizons();
            const confs = config.getConfidenceLevels();
            if (projections.pickPromotedCell(&config, as_of, confs)) |pc| {
                const cell = grid[pc.confidence_index * horizons.len + pc.horizon_index];
                if (cell.accumulation_years) |n| {
                    // Promoted cell is feasible: synthesize a
                    // retirement date by walking N years out from
                    // `as_of` (preserves the reference date's m/d,
                    // matching the calendar-precise treatment of
                    // `retirement_at`).
                    const ret_date = Date.fromYmd(
                        as_of.year() + @as(i16, @intCast(n)),
                        as_of.month(),
                        as_of.day(),
                    );
                    retirement = .{
                        .accumulation_years = n,
                        .date = ret_date,
                        .source = .promoted,
                    };
                    // Recompute accumulation_stats using the
                    // promoted N — the target-retirement-date path
                    // computes these from the percentile bands, but
                    // the target-spending cell already carries
                    // median/p10/p90 at retirement, so reuse them.
                    accumulation_stats = .{
                        .median_at_retirement = cell.median_at_retirement,
                        .p10_at_retirement = cell.p10_at_retirement,
                        .p90_at_retirement = cell.p90_at_retirement,
                        .annual_contribution = config.annual_contribution,
                        .contribution_inflation_adjusted = config.contribution_inflation_adjusted,
                    };
                } else {
                    // Promoted cell is infeasible: render the line
                    // as "not feasible" and skip the stats lines.
                    retirement = .{
                        .accumulation_years = 0,
                        .date = null,
                        .source = .promoted_infeasible,
                    };
                    accumulation_stats = null;
                }
            }
        }
    }

    return .{
        .comparison = comparison,
        .config = config,
        .data = data,
        .stock_pct = stock_pct,
        .bond_pct = bond_pct,
        .total_value = total_value,
        .retirement = retirement,
        .accumulation = accumulation_stats,
        .earliest = earliest,
        .inputs = inputs,
    };
}

/// Load and compute a complete ProjectionContext from a portfolio path and service.
///
/// This is the single entry point for both CLI and TUI. It handles:
///   - Loading projections.srf and metadata.srf from the portfolio directory
///   - Deriving stock/bond allocation from classification metadata
///   - Computing benchmark trailing returns (SPY + AGG)
///   - Building per-position weighted trailing returns
///   - Running the FIRECalc simulation for all horizons and confidence levels
///
/// The caller provides the portfolio summary (allocations, total value, cash/CD)
/// and a DataService for candle access. All intermediate allocations use `alloc`.
pub fn loadProjectionContext(
    io: std.Io,
    alloc: std.mem.Allocator,
    portfolio_dir: []const u8,
    allocations: []const valuation.Allocation,
    total_value: f64,
    cash_value: f64,
    cd_value: f64,
    svc: *zfin.DataService,
    events_enabled: bool,
    as_of: Date,
) !ProjectionContext {
    return buildContextFromParts(
        io,
        alloc,
        portfolio_dir,
        allocations,
        total_value,
        cash_value,
        cd_value,
        svc,
        events_enabled,
        as_of,
    );
}

// ── As-of (historical) projection context ──────────────────────
//
// Retrospective projections. Builds a ProjectionContext as if it were
// a past `as_of` date, using a stored snapshot for portfolio
// composition and truncating benchmark candle history at `as_of` so
// trailing returns reflect what was knowable at that moment.
//
// The snapshot→allocations aggregation (`SnapshotAllocations` and
// `aggregateSnapshotAllocations`) lives in `src/history.zig` next to
// the other snapshot-domain helpers. This module orchestrates the
// full projection pipeline through `buildContextFromParts`.
//
// Known limitation: `projections.srf` and `metadata.srf` are still
// loaded from the current working copy (git-tracked). This means
// retirement ages, target allocation, confidence levels, and symbol
// classifications are all "as of now", not as of the requested date.
// Documented edge case; see TODO.md.

/// Build a complete `ProjectionContext` as of a historical `as_of_date`.
///
/// Mirrors `loadProjectionContext` but sources portfolio composition
/// from a snapshot instead of the live portfolio file:
///   - Allocations derived from snapshot's lot rows
///   - Total value / cash / CD taken from snapshot
///   - Benchmark candles truncated to <= as_of_date
///   - Per-symbol trailing returns truncated to <= as_of_date
///   - Life events resolved against ages-as-of-as_of via
///     `UserConfig.currentAges`
///
/// Known as-of limitations (documented):
///   - `metadata.srf` classifications are current, not historical.
///     Symbols reclassified since the snapshot use the new class.
///   - `projections.srf` config (retirement ages, horizons, target
///     allocation) is current, not historical.
///   - Per-symbol candles older than ~10yr may be missing from cache,
///     causing null trailing returns for older windows.
///
/// Caller owns the returned context. `snap` must outlive the context
/// (allocation symbol strings borrow from the snapshot's backing
/// buffer — see `history.aggregateSnapshotAllocations`).
pub fn loadProjectionContextAsOf(
    io: std.Io,
    alloc: std.mem.Allocator,
    portfolio_dir: []const u8,
    snap: *const snapshot_model.Snapshot,
    as_of_date: Date,
    svc: *zfin.DataService,
    events_enabled: bool,
) !ProjectionContext {
    var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap);
    defer snap_allocs.deinit(alloc);

    var ctx = try buildContextFromParts(
        io,
        alloc,
        portfolio_dir,
        snap_allocs.allocations,
        snap_allocs.total_value,
        snap_allocs.cash_value,
        snap_allocs.cd_value,
        svc,
        events_enabled,
        as_of_date,
    );
    ctx.as_of_source = .snapshot;
    return ctx;
}

/// Build a `ProjectionContext` for an as-of date that has only an
/// `imported_values.srf` row — no native `*-portfolio.srf` snapshot.
///
/// We can't reconstruct the historical lot composition from just a
/// `liquid` total, so we use **today's allocations** scaled to the
/// imported liquid value. The simulation then runs with:
///   - Today's stock/bond split (ratio-preserving),
///   - Per-position trailing returns truncated to <= as_of,
///   - `total_value = imported_liquid` (the simulation starting point),
///   - Cash/CD totals scaled by the same factor.
///
/// This is the best approximation available for back-dated runs that
/// predate native snapshots — useful for users with weekly imported
/// history going back years. Limitations are documented in the
/// caveat surfaced by the calling display layer.
///
/// Caller-provided `live_allocations` is from the current portfolio
/// (live `portfolioSummary().allocations`), `live_total_value` is the
/// matching `summary.total_value`, etc. The function builds a
/// freshly-allocated, scaled copy of the allocations slice and frees
/// it before returning — `buildContextFromParts` only borrows
/// `allocations` during the build (to derive the stock/bond split
/// and per-position trailing returns) and doesn't store it on the
/// context. Same lifetime convention as
/// `loadProjectionContextAsOf`, which similarly frees its
/// snapshot-derived allocations slice via `defer snap_allocs.deinit`.
pub fn loadProjectionContextFromImported(
    io: std.Io,
    alloc: std.mem.Allocator,
    portfolio_dir: []const u8,
    live_allocations: []const valuation.Allocation,
    live_total_value: f64,
    live_cash_value: f64,
    live_cd_value: f64,
    imported_liquid: f64,
    as_of_date: Date,
    svc: *zfin.DataService,
    events_enabled: bool,
) !ProjectionContext {
    // Defensive: degenerate live total. Skip scaling and pass through;
    // simulation will produce flat bands anchored at imported_liquid
    // either way.
    const scale: f64 = if (live_total_value > 0) imported_liquid / live_total_value else 1.0;

    // Scale a copy of the allocations. The slice is consumed by
    // `buildContextFromParts` during the build (split derivation,
    // per-position trailing returns) but not retained on the
    // returned context — so we free it here, mirroring the snapshot
    // path's `defer snap_allocs.deinit(alloc)`.
    const scaled = try alloc.alloc(valuation.Allocation, live_allocations.len);
    defer alloc.free(scaled);
    for (live_allocations, 0..) |a, i| {
        scaled[i] = a;
        scaled[i].market_value = a.market_value * scale;
        // Weight is preserved (it's a ratio); shares/cost stay as
        // today's because the simulation only consumes weight +
        // total_value. Carrying real share counts at imported scale
        // would be misleading — they don't reflect history.
    }

    var ctx = try buildContextFromParts(
        io,
        alloc,
        portfolio_dir,
        scaled,
        imported_liquid,
        live_cash_value * scale,
        live_cd_value * scale,
        svc,
        events_enabled,
        as_of_date,
    );
    ctx.as_of_source = .imported;
    return ctx;
}

/// Shared core: build a `ProjectionContext` from pre-computed
/// allocations and totals. Both `loadProjectionContext` (live) and
/// `loadProjectionContextAsOf` (historical) delegate here.
///
/// `as_of` gates two behaviors:
///   - `null` → live mode. Benchmark + per-symbol candles used as-is;
///     events resolved against current ages (`resolveEvents()`).
///   - `|d|`  → historical mode. Benchmark + per-symbol candles sliced
///     to `<= d`; events resolved against ages-as-of-d
///     (`resolveEventsWithAges(currentAgesAsOf(d))`).
fn buildContextFromParts(
    io: std.Io,
    alloc: std.mem.Allocator,
    portfolio_dir: []const u8,
    allocations: []const valuation.Allocation,
    total_value: f64,
    cash_value: f64,
    cd_value: f64,
    svc: *zfin.DataService,
    events_enabled: bool,
    as_of: Date,
) !ProjectionContext {
    // Load projections.srf
    const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir});
    defer alloc.free(proj_path);
    const proj_data = std.Io.Dir.cwd().readFileAlloc(io, proj_path, alloc, .limited(64 * 1024)) catch null;
    defer if (proj_data) |d| alloc.free(d);
    var config = projections.parseProjectionsConfig(proj_data);
    if (!events_enabled) config.event_count = 0;

    // Resolve age-based horizons (if any) against `as_of`. The caller
    // chooses whether `as_of` is today (live mode) or a historical
    // backfill date. This turns
    // `horizon_age:num:N` records into concrete year counts appended to
    // `config.horizons` — see `UserConfig.resolveHorizonAges`.
    const horizon_anchor = as_of;
    try config.resolveHorizonAges(horizon_anchor);

    // Load metadata for classification
    const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir});
    defer alloc.free(meta_path);
    const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, alloc, .limited(1024 * 1024)) catch null;
    defer if (meta_data) |d| alloc.free(d);
    var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d|
        zfin.classification.parseClassificationFile(alloc, d) catch null
    else
        null;
    defer if (cm_opt) |*cm| cm.deinit();

    // Derive stock/bond split
    const split = benchmark.deriveAllocationSplit(
        allocations,
        if (cm_opt) |cm| cm.entries else &.{},
        total_value,
        cash_value,
        cd_value,
    );

    // Fetch benchmark candles (checks cache first). In historical
    // mode we slice to `<= as_of` — `performance.trailingReturns`
    // anchors on the last candle's date, so trimming the tail gives
    // returns "as of" that date for free.
    //
    // Symbols default to SPY/AGG; user can override via
    // `type::config,benchmark_stock::SYMBOL` and
    // `type::config,benchmark_bond::SYMBOL` in projections.srf.
    const stock_sym = config.benchmark_stock;
    const bond_sym = config.benchmark_bond;
    const spy_result = svc.getCandles(stock_sym, .{}) catch null;
    defer if (spy_result) |r| r.deinit();
    const spy_candles = history.sliceCandlesAsOf(
        if (spy_result) |r| r.data else &.{},
        as_of,
    );

    const agg_result = svc.getCandles(bond_sym, .{}) catch null;
    defer if (agg_result) |r| r.deinit();
    const agg_candles = history.sliceCandlesAsOf(
        if (agg_result) |r| r.data else &.{},
        as_of,
    );

    const spy_trailing = performance.trailingReturns(spy_candles);
    const agg_trailing = performance.trailingReturns(agg_candles);

    // Build per-position trailing returns from cached candles, each
    // optionally truncated to the as-of date. `trailingReturns`
    // populates `.week` per position; `portfolioWeightedReturns`
    // aggregates the week the same way as the longer periods.
    var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty;
    defer pos_returns.deinit(alloc);
    for (allocations) |a| {
        const candles_res = svc.getCachedCandles(alloc, a.symbol) orelse continue;
        defer candles_res.deinit();
        const candles = history.sliceCandlesAsOf(candles_res.data, as_of);
        if (candles.len > 0) {
            try pos_returns.append(alloc, .{
                .symbol = a.symbol,
                .weight = a.weight,
                .returns = performance.trailingReturns(candles),
            });
        }
    }

    const comparison = benchmark.buildComparison(
        spy_trailing,
        agg_trailing,
        split.stock_pct,
        split.bond_pct,
        pos_returns.items,
    );

    // Resolve events against ages-as-of the reference date. The
    // caller chooses whether `as_of` is today (live mode) or a
    // historical backfill date — the math is the same either way.
    const resolved_events = blk: {
        const resolved = config.resolveEvents(as_of);
        break :blk resolved[0..config.event_count];
    };

    return buildProjectionContext(
        alloc,
        config,
        comparison,
        split.stock_pct,
        split.bond_pct,
        total_value,
        resolved_events,
        as_of,
    );
}

// ── Accumulation phase / earliest retirement display blocks ────

/// Format the "Years until possible retirement: …" line. The line is
/// always present in projections output for transparency, including
/// the `none` and infeasible cases.
///
/// When `config` has at least one birthdate AND the retirement is
/// resolved to a date, the configured persons' ages on the
/// retirement date are appended in birthdate-record order, separated
/// by '/'. Ages use whole-year integer math (`yearsBetween` floored).
///
/// Output forms:
///   - `.none`                  → "Years until possible retirement: none"
///   - `.at_date` / `.at_age` / `.promoted` (with date) →
///     "Years until possible retirement: 10 (2036-07-01, ages 65/62)"
///   - `.promoted_infeasible`   → "Years until possible retirement: not feasible"
///
/// Buffer should be at least 128 bytes; the worst-case 4-person line
/// fits comfortably.
///
/// For renderers that want to color just the value portion (e.g.
/// "not feasible" in red while keeping the label neutral), use
/// `splitRetirementLine` instead — this function returns the full
/// concatenated line for callers that don't care about styling.
pub fn fmtRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) []const u8 {
    const parts = splitRetirementLine(buf, resolved, config);
    // Re-concatenate when the caller doesn't want the split.
    // Both halves already point into `buf`, in order, so we only
    // need to return the contiguous slice spanning both.
    if (parts.value_text.len == 0) return parts.label_text;
    const start = @intFromPtr(parts.label_text.ptr) - @intFromPtr(buf.ptr);
    const end = (@intFromPtr(parts.value_text.ptr) - @intFromPtr(buf.ptr)) + parts.value_text.len;
    return buf[start..end];
}

/// Pre-formatted retirement line split into a neutral label and a
/// value portion that the caller may want to render in a different
/// style. The value carries the StyleIntent; the label is always
/// rendered in the default content color.
///
/// Concatenation rule: `<label_text><value_text>` produces the full
/// line. Both slices point into `buf` and are contiguous.
pub const RetirementLineParts = struct {
    /// Always-neutral label, e.g. "Years until possible retirement: ".
    label_text: []const u8,
    /// Value portion the caller may style. Empty when the resolved
    /// state has no separate value (currently never — even the
    /// `none` case puts "none" here).
    value_text: []const u8,
    /// Suggested style for the value portion. `.normal` for dates,
    /// `.negative` for "not feasible", `.muted` for "none".
    value_style: StyleIntent,
};

pub fn splitRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, config: *const projections.UserConfig) RetirementLineParts {
    const label = "Years until possible retirement: ";

    if (resolved.source == .none) {
        // "Years until possible retirement: " + "none"
        const written = std.fmt.bufPrint(buf, "{s}none", .{label}) catch {
            return .{ .label_text = "Years until possible retirement: ", .value_text = "none", .value_style = .muted };
        };
        return .{
            .label_text = written[0..label.len],
            .value_text = written[label.len..],
            .value_style = .muted,
        };
    }

    if (resolved.source == .promoted_infeasible) {
        const written = std.fmt.bufPrint(buf, "{s}not feasible", .{label}) catch {
            return .{ .label_text = "Years until possible retirement: ", .value_text = "not feasible", .value_style = .negative };
        };
        return .{
            .label_text = written[0..label.len],
            .value_text = written[label.len..],
            .value_style = .negative,
        };
    }

    var date_buf: [10]u8 = undefined;
    const date_str = if (resolved.date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";

    // Build the optional ", ages A/B/..." suffix when birthdates are
    // configured and we have a retirement date. Skipping is safe and
    // makes the function tolerant of zero-birthdate configs.
    var ages_buf: [64]u8 = undefined;
    var ages_len: usize = 0;
    if (resolved.date) |d| {
        if (config.birthdate_count > 0) {
            // ", age " (single) or ", ages " (multiple) — singular
            // form when only one person is configured matches normal
            // English usage.
            const prefix: []const u8 = if (config.birthdate_count == 1) ", age " else ", ages ";
            if (ages_len + prefix.len > ages_buf.len) return shortParts(buf, resolved.accumulation_years, date_str);
            @memcpy(ages_buf[ages_len .. ages_len + prefix.len], prefix);
            ages_len += prefix.len;

            var i: u8 = 0;
            while (i < config.birthdate_count) : (i += 1) {
                if (i > 0) {
                    if (ages_len + 1 > ages_buf.len) return shortParts(buf, resolved.accumulation_years, date_str);
                    ages_buf[ages_len] = '/';
                    ages_len += 1;
                }
                const age = config.birthdates[i].ageOn(d);
                const age_str = std.fmt.bufPrint(ages_buf[ages_len..], "{d}", .{age}) catch
                    return shortParts(buf, resolved.accumulation_years, date_str);
                ages_len += age_str.len;
            }
        }
    }

    if (ages_len == 0) return shortParts(buf, resolved.accumulation_years, date_str);

    const written = std.fmt.bufPrint(buf, "{s}{d} ({s}{s})", .{
        label,
        resolved.accumulation_years,
        date_str,
        ages_buf[0..ages_len],
    }) catch return shortParts(buf, resolved.accumulation_years, date_str);
    return .{
        .label_text = written[0..label.len],
        .value_text = written[label.len..],
        .value_style = .normal,
    };
}

/// Fallback used when the ages suffix can't be rendered (no
/// birthdates, ages buffer overflow, or format failure). Keeps the
/// "Years until possible retirement: N (DATE)" form intact, with
/// the value portion styled `.normal`.
fn shortParts(buf: []u8, years: u16, date_str: []const u8) RetirementLineParts {
    const label = "Years until possible retirement: ";
    const written = std.fmt.bufPrint(buf, "{s}{d} ({s})", .{ label, years, date_str }) catch {
        return .{
            .label_text = "Years until possible retirement: ",
            .value_text = "?",
            .value_style = .normal,
        };
    };
    return .{
        .label_text = written[0..label.len],
        .value_text = written[label.len..],
        .value_style = .normal,
    };
}

/// Format the "Annual contributions: $X (CPI-adjusted)" line.
///
/// Returns null when the contribution is zero AND the caller has no
/// accumulation phase configured — the line would be pure noise. The
/// caller should still render the retirement line in that case;
/// suppressing the contribution row alone keeps the block tidy.
pub fn fmtContributionLine(arena: std.mem.Allocator, amount: f64, inflation_adjusted: bool, accumulation_years: u16) !?[]const u8 {
    if (amount == 0 and accumulation_years == 0) return null;
    const adj_note: []const u8 = if (inflation_adjusted) "  (CPI-adjusted)" else "  (nominal)";
    return try std.fmt.allocPrint(arena, "Annual contributions:            {f}{s}", .{ Money.from(amount).trim(), adj_note });
}

/// A single cell in the "Earliest retirement" grid: either a formatted
/// date string or "not feasible" muted text.
pub const EarliestCell = struct {
    text: []const u8,
    style: StyleIntent,
};

/// Format an `EarliestRetirement` cell as a date string anchored
/// against `as_of` (the projection's reference date — pass today
/// for live mode, or a historical date for back-dated runs).
/// Reports the date the user reaches the `accumulation_years`
/// threshold, using `as_of`'s m/d for the calendar display.
///
/// Cells where no value of `accumulation_years` ≤ `max_accumulation_years`
/// sustains the target spending render "infeasible" with the
/// `.negative` style — this is critical information for the user
/// (you can't retire under these conditions) and must NOT be
/// muted. Single word so it fits in the standard 14-char grid
/// column. The retirement line above the grid uses the longer
/// "not feasible" form when the promoted cell falls in this state
/// — both forms mean the same thing; the asymmetry is layout-driven.
pub fn fmtEarliestCell(arena: std.mem.Allocator, er: projections.EarliestRetirement, as_of: Date) !EarliestCell {
    const n = er.accumulation_years orelse {
        return .{ .text = "infeasible", .style = .negative };
    };
    if (n == 0) {
        return .{ .text = try std.fmt.allocPrint(arena, "now", .{}), .style = .normal };
    }
    // Anchor against the reference date's year/month/day. The
    // earliest-retirement search produces an integer year offset;
    // rendering a calendar-precise date requires picking some
    // month/day. Use `as_of`'s m/d so the displayed year matches
    // "N years from the reference date".
    const ret_date = Date.fromYmd(as_of.year() + @as(i16, @intCast(n)), as_of.month(), as_of.day());
    var dbuf: [10]u8 = undefined;
    const dstr = std.fmt.bufPrint(&dbuf, "{f}", .{ret_date}) catch "????-??-??";
    return .{ .text = try arena.dupe(u8, dstr), .style = .normal };
}

/// Build a row in the "Earliest retirement" grid for a single
/// confidence level. The row label is the confidence percentage; one
/// column per horizon, each rendering a date or "—".
pub const EarliestRow = struct {
    label_text: []const u8,
    cells: []EarliestCell,
};

pub fn buildEarliestRow(
    arena: std.mem.Allocator,
    confidence: f64,
    horizons: []const u16,
    earliest: []const projections.EarliestRetirement,
    confidence_idx: usize,
    as_of: Date,
) !EarliestRow {
    const label = try std.fmt.allocPrint(arena, "{d:.0}% confidence", .{confidence * 100});
    const cells = try arena.alloc(EarliestCell, horizons.len);
    for (horizons, 0..) |_, hi| {
        cells[hi] = try fmtEarliestCell(arena, earliest[confidence_idx * horizons.len + hi], as_of);
    }
    return .{ .label_text = label, .cells = cells };
}

// ── Table row builders (shared by CLI and TUI) ─────────────────

/// A pre-formatted table row: label + right-aligned columns.
pub const TableRow = struct {
    text: []const u8,
    style: StyleIntent,
};

/// Build a column header row for a given set of horizons and column width.
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 {
    var row: std.ArrayListUnmanaged(u8) = .empty;
    try row.appendNTimes(arena, ' ', withdrawal_label_width);
    for (horizons) |h| {
        var hbuf: [16]u8 = undefined;
        const hlabel = fmtHorizonLabel(&hbuf, h);
        try row.appendNTimes(arena, ' ', col_width -| hlabel.len);
        try row.appendSlice(arena, hlabel);
    }
    return row.toOwnedSlice(arena);
}

/// Build withdrawal rows for one confidence level: amount row + rate row.
pub fn buildWithdrawalRows(
    arena: std.mem.Allocator,
    confidence: f64,
    horizons: []const u16,
    cached_results: []const projections.WithdrawalResult,
    confidence_idx: usize,
) !struct { amount: TableRow, rate: TableRow } {
    // Amount row
    var amount_row: std.ArrayListUnmanaged(u8) = .empty;
    var lbuf: [25]u8 = undefined;
    const clabel = fmtConfidenceLabel(&lbuf, confidence);
    try amount_row.appendSlice(arena, clabel);
    try amount_row.appendNTimes(arena, ' ', withdrawal_label_width -| clabel.len);

    for (horizons, 0..) |_, hi| {
        const result = cached_results[confidence_idx * horizons.len + hi];
        var abuf: [24]u8 = undefined;
        var rbuf: [16]u8 = undefined;
        const cell = fmtWithdrawalCell(&abuf, &rbuf, result);
        try amount_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.amount_text.len);
        try amount_row.appendSlice(arena, cell.amount_text);
    }

    // Rate row
    var rate_row: std.ArrayListUnmanaged(u8) = .empty;
    try rate_row.appendNTimes(arena, ' ', withdrawal_label_width);

    for (horizons, 0..) |_, hi| {
        const result = cached_results[confidence_idx * horizons.len + hi];
        var abuf: [24]u8 = undefined;
        var rbuf: [16]u8 = undefined;
        const cell = fmtWithdrawalCell(&abuf, &rbuf, result);
        try rate_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.rate_text.len);
        try rate_row.appendSlice(arena, cell.rate_text);
    }

    return .{
        .amount = .{ .text = try amount_row.toOwnedSlice(arena), .style = .normal },
        .rate = .{ .text = try rate_row.toOwnedSlice(arena), .style = .muted },
    };
}

/// Build a percentile row (p10/p50/p90) across horizons.
pub fn buildPercentileRow(
    arena: std.mem.Allocator,
    label: []const u8,
    percentile_idx: usize,
    all_bands: []const ?[]const projections.YearPercentiles,
    style: StyleIntent,
) !TableRow {
    var row: std.ArrayListUnmanaged(u8) = .empty;
    try row.appendSlice(arena, label);
    try row.appendNTimes(arena, ' ', withdrawal_label_width -| label.len);

    for (all_bands) |bands_opt| {
        if (bands_opt) |bands| {
            if (bands.len > 0) {
                const last = bands[bands.len - 1];
                const val = switch (percentile_idx) {
                    0 => last.p10,
                    1 => last.p50,
                    2 => last.p90,
                    else => 0,
                };
                var mbuf: [24]u8 = undefined;
                const txt = std.fmt.bufPrint(&mbuf, "{f}", .{Money.from(val)}) catch "$?";
                try row.appendNTimes(arena, ' ', terminal_col_width -| txt.len);
                try row.appendSlice(arena, txt);
            } else {
                try row.appendNTimes(arena, ' ', terminal_col_width - 2);
                try row.appendSlice(arena, "--");
            }
        } else {
            try row.appendNTimes(arena, ' ', terminal_col_width - 2);
            try row.appendSlice(arena, "--");
        }
    }

    return .{ .text = try row.toOwnedSlice(arena), .style = style };
}

// ── Event summary (shared by CLI and TUI) ──────────────────────

pub const EventLine = struct {
    text: []const u8,
    style: StyleIntent,
};

/// Format a single event line for display.
/// Output: "  Social Security (Owner)    +$38,400/yr   age 67  (in 17yr)"
pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, current_ages: []const u16) !EventLine {
    const name = ev.getName();
    const amount = ev.annual_amount;
    const is_income = amount >= 0;
    const style: StyleIntent = if (is_income) .positive else .negative;

    var amt_buf: [24]u8 = undefined;
    const sign: []const u8 = if (is_income) "+" else "-";
    const abs_amount = @abs(amount);
    // Whole-dollar form (no decimals) for compact event-line display.
    const amt_nodec = std.fmt.bufPrint(&amt_buf, "{f}", .{Money.from(abs_amount).whole()}) catch "$?";

    const start_yr = ev.startYear(current_ages);
    const timing = if (start_yr) |sy| blk: {
        if (sy == 0)
            break :blk try std.fmt.allocPrint(arena, "age {d}  (now)", .{ev.start_age})
        else
            break :blk try std.fmt.allocPrint(arena, "age {d}  (in {d}yr)", .{ ev.start_age, sy });
    } else try std.fmt.allocPrint(arena, "age {d}", .{ev.start_age});

    const dur_str = if (ev.duration > 0)
        try std.fmt.allocPrint(arena, ", {d}yr", .{ev.duration})
    else
        "";

    const nominal_str: []const u8 = if (!ev.inflation_adjusted) ", nominal" else "";

    const text = try std.fmt.allocPrint(arena, "    {s: <28} {s}{s}/yr   {s}{s}{s}", .{
        name, sign, amt_nodec, timing, dur_str, nominal_str,
    });
    return .{ .text = text, .style = style };
}

// ── Forecast-vs-actual sub-views ──────────────────────────────
//
// `convergenceLines` and `backtestLines` produce renderer-agnostic
// pre-formatted lines for the two forecast-evaluation sub-views.
// The CLI command (`zfin projections --convergence` / `--return-backtest`)
// emits each line with ANSI per `intent`; the TUI scroll fallback
// emits each line with `theme.styleFor(intent)`. Same source of
// truth for column widths, header text, and stride logic — the
// previous implementation drift produced overflow in the TUI
// fallback, which is why this module exists at all.

/// One line in the forecast-evaluation sub-view output.
///
/// `bold` is honored by renderers that support bold (CLI via
/// `setBold`, TUI via the `vaxis.Style.bold` flag in the theme's
/// `headerStyle`). Renderers that don't support it ignore the
/// flag.
pub const ForecastLine = struct {
    text: []const u8,
    intent: StyleIntent,
    bold: bool = false,
};

/// Column widths for the convergence table. Header underlines
/// are derived from these at emit time so the dashes can never
/// drift from the data widths.
const conv_col_observed = 12; // "YYYY-MM-DD"
const conv_col_projected = 12; // "YYYY-MM-DD" or "reached"
const conv_col_years = 14; // "Years until" → "12.34"

/// Format strings derived from the column widths above. Built
/// at comptime so widths and dash counts stay in sync.
const conv_header_fmt = std.fmt.comptimePrint(
    "  {{s:<{d}}}  {{s:<{d}}}  {{s:>{d}}}",
    .{ conv_col_observed, conv_col_projected, conv_col_years },
);
const conv_sep_fmt = std.fmt.comptimePrint(
    "  {{s:-<{d}}}  {{s:-<{d}}}  {{s:->{d}}}",
    .{ conv_col_observed, conv_col_projected, conv_col_years },
);
const conv_row_fmt = std.fmt.comptimePrint(
    "  {{f}}  {{s:<{d}}}  {{s:>{d}}}",
    .{ conv_col_projected, conv_col_years },
);

/// Build the renderer-agnostic line list for the convergence
/// sub-view. Stride logic mirrors the CLI: show first, last,
/// and every Nth observation in between for ~quarterly cadence
/// on weekly imported data.
///
/// Caller owns nothing — all output strings are allocated in
/// `arena`. Output is the concatenation of `convergenceHeaderLines`
/// and `convergenceTableLines`; the two halves are exposed
/// separately so chart renderers can reuse just the header.
pub fn convergenceLines(
    arena: std.mem.Allocator,
    points: []const forecast.ConvergencePoint,
) ![]const ForecastLine {
    var lines: std.ArrayList(ForecastLine) = .empty;
    const header = try convergenceHeaderLines(arena, points);
    try lines.appendSlice(arena, header);
    if (points.len > 0) {
        const table = try convergenceTableLines(arena, points);
        try lines.appendSlice(arena, table);
    }
    return lines.toOwnedSlice(arena);
}

/// Header section for the convergence sub-view: title, range,
/// caveat. Chart renderers use this directly; table renderers
/// use it via `convergenceLines`.
pub fn convergenceHeaderLines(
    arena: std.mem.Allocator,
    points: []const forecast.ConvergencePoint,
) ![]const ForecastLine {
    var lines: std.ArrayList(ForecastLine) = .empty;

    try lines.append(arena, .{
        .text = "Projection convergence (spreadsheet-projected retirement date over time)",
        .intent = .accent,
        .bold = true,
    });

    if (points.len == 0) {
        try lines.append(arena, .{
            .text = "  No convergence data available (imported_values.srf empty or missing projected_retirement fields).",
            .intent = .muted,
        });
        return lines.toOwnedSlice(arena);
    }

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {d} observations from {f} → {f}", .{
            points.len, points[0].observation_date, points[points.len - 1].observation_date,
        }),
        .intent = .muted,
    });
    try lines.append(arena, .{
        .text = "  Caveat: tracks the model's directional honesty, not SWR validity.",
        .intent = .muted,
    });
    try lines.append(arena, .{ .text = "", .intent = .normal });

    return lines.toOwnedSlice(arena);
}

/// Table section for the convergence sub-view: column header +
/// separator + sampled body rows + optional stride caption.
/// Caller-provided `points` must be non-empty (the header
/// already handles the empty case).
pub fn convergenceTableLines(
    arena: std.mem.Allocator,
    points: []const forecast.ConvergencePoint,
) ![]const ForecastLine {
    var lines: std.ArrayList(ForecastLine) = .empty;

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, conv_header_fmt, .{ "Observed", "Projected", "Years until" }),
        .intent = .muted,
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, conv_sep_fmt, .{ "", "", "" }),
        .intent = .muted,
    });

    const stride: usize = if (points.len > 26) (points.len + 25) / 26 else 1;
    for (points, 0..) |p, i| {
        if (i != 0 and i != points.len - 1 and (i % stride) != 0) continue;

        var proj_buf: [16]u8 = undefined;
        const proj_str: []const u8 = if (p.reached)
            "reached"
        else
            std.fmt.bufPrint(&proj_buf, "{f}", .{p.projected_date}) catch "??????????";

        var years_buf: [16]u8 = undefined;
        const years_str: []const u8 = if (p.reached)
            "0.00"
        else
            std.fmt.bufPrint(&years_buf, "{d:.2}", .{p.years_until_retirement}) catch "??";

        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, conv_row_fmt, .{ p.observation_date.padLeft(conv_col_observed), proj_str, years_str }),
            .intent = .normal,
        });
    }

    if (stride > 1) {
        try lines.append(arena, .{ .text = "", .intent = .normal });
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  (Showing every {d}th observation — full chart on TUI projections tab.)", .{stride}),
            .intent = .muted,
        });
    }

    return lines.toOwnedSlice(arena);
}

/// Column widths for the back-test table. Same source of truth
/// for CLI and TUI fallback. The numeric column is 11 cols wide
/// so the em-dash sentinel can sit dead center (5 leading + dash
/// + 5 trailing); 6-char numeric values like `12.34%` end up
/// right-aligned with 5 leading spaces.
const bt_col_anchor = 12; // "YYYY-MM-DD"
const bt_col_value = 11; // "12.34%" right-aligned / "—" centered

const bt_header_fmt = std.fmt.comptimePrint(
    "  {{s:<{d}}}  {{s}}  {{s}}  {{s}}  {{s}}",
    .{bt_col_anchor},
);
const bt_sep_fmt = std.fmt.comptimePrint(
    "  {{s:-<{d}}}  {{s:->{d}}}  {{s:->{d}}}  {{s:->{d}}}  {{s:->{d}}}",
    .{ bt_col_anchor, bt_col_value, bt_col_value, bt_col_value, bt_col_value },
);
// Data-row format: numeric cells right-aligned via `{s:>11}`
// (safe — they're pure ASCII). Missing cells use the hard-coded
// `dash_cell` literal which is already 11 display cols wide; it
// passes through `{s:>11}` unchanged because the format spec
// doesn't truncate when content meets/exceeds the width.
const bt_row_fmt = std.fmt.comptimePrint(
    "  {{f}}  {{s:>{d}}}  {{s:>{d}}}  {{s:>{d}}}  {{s:>{d}}}",
    .{ bt_col_value, bt_col_value, bt_col_value, bt_col_value },
);

/// Pre-centered column headers for the back-test table. Each is
/// exactly `bt_col_value` (11) display columns wide so they line
/// up with the data rows below. Hard-coded because the labels
/// are fixed at compile time — no need for a runtime centering
/// helper.
const bt_hdr_expected = " Expected  "; // 1 lead + 8 chars + 2 trail = 11
const bt_hdr_1y = "    1y     "; // 4 lead + 2 chars + 5 trail = 11
const bt_hdr_3y = "    3y     "; // 4 lead + 2 chars + 5 trail = 11
const bt_hdr_5y = "    5y     "; // 4 lead + 2 chars + 5 trail = 11

/// Build the renderer-agnostic line list for the return-backtest
/// sub-view. `real_mode` toggles a methodology caption (whether
/// realized values are inflation-deflated). Stride logic shows
/// at most ~30 anchors.
///
/// Caller owns nothing — all output strings are allocated in
/// `arena`. Output is the concatenation of `backtestHeaderLines`
/// and `backtestTableLines`.
pub fn backtestLines(
    arena: std.mem.Allocator,
    anchors: []const forecast.BacktestAnchor,
    real_mode: bool,
) ![]const ForecastLine {
    var lines: std.ArrayList(ForecastLine) = .empty;
    const header = try backtestHeaderLines(arena, anchors, real_mode);
    try lines.appendSlice(arena, header);
    if (anchors.len > 0) {
        const table = try backtestTableLines(arena, anchors);
        try lines.appendSlice(arena, table);
    }
    return lines.toOwnedSlice(arena);
}

/// Header section for the back-test sub-view: title, range,
/// color-coded legend, methodology caption, caveat. Chart
/// renderers use this directly; table renderers use it via
/// `backtestLines`.
pub fn backtestHeaderLines(
    arena: std.mem.Allocator,
    anchors: []const forecast.BacktestAnchor,
    real_mode: bool,
) ![]const ForecastLine {
    var lines: std.ArrayList(ForecastLine) = .empty;

    try lines.append(arena, .{
        .text = "Expected vs realized return back-test",
        .intent = .accent,
        .bold = true,
    });

    if (anchors.len == 0) {
        try lines.append(arena, .{
            .text = "  No back-test data available (imported_values.srf empty or missing expected_return fields).",
            .intent = .muted,
        });
        return lines.toOwnedSlice(arena);
    }

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, "  {d} anchors from {f} → {f}", .{
            anchors.len, anchors[0].anchor_date, anchors[anchors.len - 1].anchor_date,
        }),
        .intent = .muted,
    });

    // Color-coded legend. Each line is rendered in the matching
    // chart series color (purple/cyan/yellow/green) so the user
    // can map line → series at a glance. Line styles
    // (solid/dashed/dotted) reinforce the distinction for
    // color-blind users.
    try lines.append(arena, .{
        .text = "    Expected (solid)    — projected return at each anchor date",
        .intent = .accent,
    });
    try lines.append(arena, .{
        .text = "    Realized 1y (dotted)",
        .intent = .info,
    });
    try lines.append(arena, .{
        .text = "    Realized 3y (dashed)",
        .intent = .warning,
    });
    try lines.append(arena, .{
        .text = "    Realized 5y (solid)",
        .intent = .positive,
    });

    if (real_mode) {
        try lines.append(arena, .{
            .text = "  realized = inflation-deflated forward CAGR (Shiller CPI). expected is left nominal.",
            .intent = .muted,
        });
    }
    try lines.append(arena, .{
        .text = "  Caveat: tracks the model's expected-return honesty, not SWR validity.",
        .intent = .muted,
    });
    try lines.append(arena, .{ .text = "", .intent = .normal });

    return lines.toOwnedSlice(arena);
}

/// Hard-coded em-dash sentinel cell for missing back-test
/// values. 5 leading spaces + `—` + 5 trailing spaces = 11
/// display columns. Sits dead center within the 11-col numeric
/// column.
const dash_cell = "      —    ";

/// Table section for the back-test sub-view. Caller-provided
/// `anchors` must be non-empty.
pub fn backtestTableLines(
    arena: std.mem.Allocator,
    anchors: []const forecast.BacktestAnchor,
) ![]const ForecastLine {
    var lines: std.ArrayList(ForecastLine) = .empty;

    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, bt_header_fmt, .{ "   Anchor", bt_hdr_expected, bt_hdr_1y, bt_hdr_3y, bt_hdr_5y }),
        .intent = .muted,
    });
    try lines.append(arena, .{
        .text = try std.fmt.allocPrint(arena, bt_sep_fmt, .{ "", "", "", "", "" }),
        .intent = .muted,
    });

    const stride: usize = if (anchors.len > 30) (anchors.len + 29) / 30 else 1;
    for (anchors, 0..) |a, idx| {
        if (idx != 0 and idx != anchors.len - 1 and (idx % stride) != 0) continue;

        var ebuf: [16]u8 = undefined;
        var r1_buf: [16]u8 = undefined;
        var r3_buf: [16]u8 = undefined;
        var r5_buf: [16]u8 = undefined;

        const e_cell = std.fmt.bufPrint(&ebuf, "{d:.2}% ", .{a.expected * 100}) catch "??";

        // Numeric cells are pure ASCII so the format string's
        // `{s:>10}` byte-padding lines them up correctly. Missing
        // cells use the hard-coded `dash_cell` literal which is
        // already shaped to 10 display columns (Zig's byte-padding
        // would under-pad the multibyte em-dash by 2 cols).
        const r1_cell: []const u8 = if (a.realized_1y) |v|
            std.fmt.bufPrint(&r1_buf, "{d:.2}% ", .{v * 100}) catch "??"
        else
            dash_cell;
        const r3_cell: []const u8 = if (a.realized_3y) |v|
            std.fmt.bufPrint(&r3_buf, "{d:.2}% ", .{v * 100}) catch "??"
        else
            dash_cell;
        const r5_cell: []const u8 = if (a.realized_5y) |v|
            std.fmt.bufPrint(&r5_buf, "{d:.2}% ", .{v * 100}) catch "??"
        else
            dash_cell;

        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, bt_row_fmt, .{ a.anchor_date.padLeft(bt_col_anchor), e_cell, r1_cell, r3_cell, r5_cell }),
            .intent = .normal,
        });
    }

    if (stride > 1) {
        try lines.append(arena, .{ .text = "", .intent = .normal });
        try lines.append(arena, .{
            .text = try std.fmt.allocPrint(arena, "  (Showing every {d}th anchor — full chart on TUI projections tab.)", .{stride}),
            .intent = .muted,
        });
    }

    return lines.toOwnedSlice(arena);
}

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

test "fmtReturnCell positive" {
    var buf: [16]u8 = undefined;
    const cell = fmtReturnCell(&buf, 0.1234);
    try std.testing.expect(cell.style == .positive);
    try std.testing.expect(cell.text.len > 0);
}

test "fmtReturnCell negative" {
    var buf: [16]u8 = undefined;
    const cell = fmtReturnCell(&buf, -0.05);
    try std.testing.expect(cell.style == .negative);
}

test "fmtReturnCell null" {
    var buf: [16]u8 = undefined;
    const cell = fmtReturnCell(&buf, null);
    try std.testing.expect(cell.style == .muted);
    try std.testing.expectEqualStrings("--", cell.text);
}

test "fmtWithdrawalCell strips .00" {
    var abuf: [24]u8 = undefined;
    var rbuf: [16]u8 = undefined;
    const cell = fmtWithdrawalCell(&abuf, &rbuf, .{
        .confidence = 0.99,
        .annual_amount = 305000,
        .withdrawal_rate = 0.0366,
    });
    try std.testing.expect(!std.mem.endsWith(u8, cell.amount_text, ".00"));
    try std.testing.expect(std.mem.indexOf(u8, cell.rate_text, "3.66") != null);
}

test "fmtAllocationNote on target" {
    var buf: [128]u8 = undefined;
    const note = fmtAllocationNote(&buf, 77, 0.768);
    try std.testing.expect(note != null);
    try std.testing.expect(note.?.style == .muted);
    try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") != null);
}

test "fmtAllocationNote off target" {
    var buf: [128]u8 = undefined;
    const note = fmtAllocationNote(&buf, 77, 0.90);
    try std.testing.expect(note != null);
    try std.testing.expect(note.?.style == .negative); // 13% drift, >10%
    try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") == null);
}

test "fmtAllocationNote warning range" {
    var buf: [128]u8 = undefined;
    const note = fmtAllocationNote(&buf, 77, 0.84);
    try std.testing.expect(note != null);
    try std.testing.expect(note.?.style == .warning); // 7% drift, in 5-10% range
}

test "fmtAllocationNote no target" {
    var buf: [128]u8 = undefined;
    try std.testing.expect(fmtAllocationNote(&buf, null, 0.75) == null);
}

test "fmtBenchmarkLabel" {
    var buf: [32]u8 = undefined;
    const label = fmtBenchmarkLabel(&buf, "SPY", 83.8);
    try std.testing.expect(std.mem.indexOf(u8, label, "SPY") != null);
    try std.testing.expect(std.mem.indexOf(u8, label, "83.8") != null);
}

test "buildReturnRow" {
    var bufs: [5][16]u8 = undefined;
    const returns = benchmark.ReturnsByPeriod{
        .one_year = 0.15,
        .three_year = -0.02,
        .five_year = null,
    };
    const row = buildReturnRow("Test", returns, &bufs, false);
    try std.testing.expectEqualStrings("Test", row.label);
    try std.testing.expect(row.one_year.style == .positive);
    try std.testing.expect(row.three_year.style == .negative);
    try std.testing.expect(row.five_year.style == .muted);
    try std.testing.expect(row.bold == false);
}

test "buildHeaderRow formats horizons" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const horizons = [_]u16{ 30, 45 };
    const result = try buildHeaderRow(a, &horizons, withdrawal_col_width);
    try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
    try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null);
}

test "buildHeaderRow uses terminal column width" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const horizons = [_]u16{20};
    const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width);
    const wide = try buildHeaderRow(a, &horizons, terminal_col_width);
    try std.testing.expect(wide.len > narrow.len);
}

test "buildWithdrawalRows produces amount and rate" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const horizons = [_]u16{ 30, 45 };
    const results = [_]projections.WithdrawalResult{
        .{ .confidence = 0.95, .annual_amount = 350000, .withdrawal_rate = 0.042 },
        .{ .confidence = 0.95, .annual_amount = 310000, .withdrawal_rate = 0.037 },
    };

    const rows = try buildWithdrawalRows(a, 0.95, &horizons, &results, 0);
    // Amount row should contain the dollar amounts
    try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "350,000") != null);
    try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "310,000") != null);
    try std.testing.expect(rows.amount.style == .normal);
    // Rate row should contain percentages
    try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "4.20%") != null);
    try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "3.70%") != null);
    try std.testing.expect(rows.rate.style == .muted);
}

test "buildPercentileRow extracts correct percentile" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const bands = [_]projections.YearPercentiles{
        .{ .year = 0, .p10 = 1000000, .p25 = 2000000, .p50 = 3000000, .p75 = 4000000, .p90 = 5000000 },
        .{ .year = 30, .p10 = 5000000, .p25 = 10000000, .p50 = 20000000, .p75 = 30000000, .p90 = 50000000 },
    };
    const band_slice: []const projections.YearPercentiles = &bands;
    const all_bands = [_]?[]const projections.YearPercentiles{band_slice};

    // p10 (index 0)
    const row_p10 = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted);
    try std.testing.expect(std.mem.indexOf(u8, row_p10.text, "5,000,000") != null);
    try std.testing.expect(row_p10.style == .muted);

    // p50 (index 1)
    const row_p50 = try buildPercentileRow(a, "Median", 1, &all_bands, .normal);
    try std.testing.expect(std.mem.indexOf(u8, row_p50.text, "20,000,000") != null);
    try std.testing.expect(row_p50.style == .normal);

    // p90 (index 2)
    const row_p90 = try buildPercentileRow(a, "Optimistic", 2, &all_bands, .muted);
    try std.testing.expect(std.mem.indexOf(u8, row_p90.text, "50,000,000") != null);
}

test "buildPercentileRow handles null bands" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const a = arena.allocator();

    const all_bands = [_]?[]const projections.YearPercentiles{null};
    const row = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted);
    try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null);
}

test "fmtConfidenceLabel" {
    var buf: [25]u8 = undefined;
    const label = fmtConfidenceLabel(&buf, 0.99);
    try std.testing.expect(std.mem.indexOf(u8, label, "99%") != null);
    try std.testing.expect(std.mem.indexOf(u8, label, "withdrawal") != null);
}

test "fmtHorizonLabel" {
    var buf: [16]u8 = undefined;
    const label = fmtHorizonLabel(&buf, 30);
    try std.testing.expectEqualStrings("30 Year", label);
}

// ── Accumulation phase / earliest retirement view tests ────────

test "fmtRetirementLine: none" {
    var buf: [128]u8 = undefined;
    const config = projections.UserConfig{};
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 0,
        .date = null,
        .source = .none,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: none", line);
}

test "fmtRetirementLine: at_date with no birthdates omits ages suffix" {
    var buf: [128]u8 = undefined;
    const config = projections.UserConfig{};
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 10,
        .date = Date.fromYmd(2036, 7, 1),
        .source = .at_date,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line);
}

test "fmtRetirementLine: at_age with no birthdates omits ages suffix" {
    var buf: [128]u8 = undefined;
    const config = projections.UserConfig{};
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 14,
        .date = Date.fromYmd(2040, 3, 15),
        .source = .at_age,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line);
}

test "fmtRetirementLine: at_age with one birthdate appends singular age suffix" {
    var buf: [128]u8 = undefined;
    var config = projections.UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1975, 3, 15); // age 65 on 2040-03-15
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 14,
        .date = Date.fromYmd(2040, 3, 15),
        .source = .at_age,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15, age 65)", line);
}

test "fmtRetirementLine: at_age with two birthdates uses plural ages" {
    var buf: [128]u8 = undefined;
    var config = projections.UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12); // age 65 on 2046-04-12
    config.birthdates[1] = Date.fromYmd(1983, 9, 8); // age 62 on 2046-04-12
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 19,
        .date = Date.fromYmd(2046, 4, 12),
        .source = .at_age,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, ages 65/62)", line);
}

test "fmtRetirementLine: ages are floored to whole years" {
    // Born 1981-06-01; retirement on 2046-04-12 — birthday hasn't
    // occurred yet that year, so age is 64 (not 65).
    var buf: [128]u8 = undefined;
    var config = projections.UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1981, 6, 1);
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 19,
        .date = Date.fromYmd(2046, 4, 12),
        .source = .at_age,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, age 64)", line);
}

test "fmtRetirementLine: promoted source renders dated form like at_date" {
    var buf: [128]u8 = undefined;
    var config = projections.UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12);
    config.birthdates[1] = Date.fromYmd(1983, 9, 8);
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 19,
        .date = Date.fromYmd(2046, 4, 12),
        .source = .promoted,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: 19 (2046-04-12, ages 65/62)", line);
}

test "fmtRetirementLine: promoted_infeasible renders 'not feasible'" {
    var buf: [128]u8 = undefined;
    const config = projections.UserConfig{};
    const line = fmtRetirementLine(&buf, .{
        .accumulation_years = 0,
        .date = null,
        .source = .promoted_infeasible,
    }, &config);
    try std.testing.expectEqualStrings("Years until possible retirement: not feasible", line);
}

test "fmtContributionLine: zero contribution and zero accumulation -> null" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const result = try fmtContributionLine(arena.allocator(), 0, true, 0);
    try std.testing.expect(result == null);
}

test "fmtContributionLine: nonzero contribution renders" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const result = try fmtContributionLine(arena.allocator(), 100_000, true, 10);
    try std.testing.expect(result != null);
    try std.testing.expect(std.mem.indexOf(u8, result.?, "100,000") != null);
    try std.testing.expect(std.mem.indexOf(u8, result.?, "CPI-adjusted") != null);
}

test "fmtContributionLine: nominal flag changes label" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const result = try fmtContributionLine(arena.allocator(), 50_000, false, 5);
    try std.testing.expect(result != null);
    try std.testing.expect(std.mem.indexOf(u8, result.?, "nominal") != null);
    try std.testing.expect(std.mem.indexOf(u8, result.?, "CPI-adjusted") == null);
}

test "fmtContributionLine: zero contribution but with accumulation still renders" {
    // User pauses contributions but isn't retired yet — legitimate
    // case; the line should appear so the user sees that the model
    // is treating contributions as zero.
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const result = try fmtContributionLine(arena.allocator(), 0, true, 5);
    try std.testing.expect(result != null);
}

test "fmtEarliestCell: feasible at N=0 -> 'now'" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const cell = try fmtEarliestCell(arena.allocator(), .{
        .horizon = 30,
        .confidence = 0.95,
        .accumulation_years = 0,
        .median_at_retirement = 0,
        .p10_at_retirement = 0,
        .p90_at_retirement = 0,
    }, Date.fromYmd(2026, 5, 12));
    try std.testing.expectEqualStrings("now", cell.text);
}

test "fmtEarliestCell: infeasible -> 'infeasible' label, negative style" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const cell = try fmtEarliestCell(arena.allocator(), .{
        .horizon = 30,
        .confidence = 0.99,
        .accumulation_years = null,
        .median_at_retirement = 0,
        .p10_at_retirement = 0,
        .p90_at_retirement = 0,
    }, Date.fromYmd(2026, 5, 12));
    try std.testing.expectEqualStrings("infeasible", cell.text);
    // .negative — NOT .muted. "You can't retire under these
    // conditions" is critical info; muting it would bury the
    // headline. Style matches CLR_NEGATIVE convention used for
    // losses elsewhere in the UI.
    try std.testing.expectEqual(@as(StyleIntent, .negative), cell.style);
}

test "fmtEarliestCell: N=10 produces date 10 years from today" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const cell = try fmtEarliestCell(arena.allocator(), .{
        .horizon = 30,
        .confidence = 0.95,
        .accumulation_years = 10,
        .median_at_retirement = 0,
        .p10_at_retirement = 0,
        .p90_at_retirement = 0,
    }, Date.fromYmd(2026, 5, 12));
    try std.testing.expectEqualStrings("2036-05-12", cell.text);
}

test "buildProjectionContext: distribution-only inputs when no accumulation fields" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    const config = projections.UserConfig{};
    const comparison: benchmark.BenchmarkComparison = .{
        .stock_returns = .{},
        .bond_returns = .{},
        .benchmark_returns = .{},
        .portfolio_returns = .{},
        .conservative_return = 0.07,
        .stock_pct = 0.75,
        .bond_pct = 0.25,
    };

    var ctx = try buildProjectionContext(
        arena.allocator(),
        config,
        comparison,
        0.75,
        0.25,
        1_000_000,
        &.{},
        Date.fromYmd(2026, 5, 12),
    );
    _ = &ctx;

    try std.testing.expectEqual(ProjectionInputs.distribution_only, ctx.inputs);
    try std.testing.expectEqual(@as(usize, 0), ctx.retirement.accumulation_years);
    try std.testing.expect(ctx.accumulation == null);
    try std.testing.expect(ctx.earliest == null);
}

test "buildProjectionContext: target_retirement_date inputs with retirement_at" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var config = projections.UserConfig{};
    config.retirement_at = Date.fromYmd(2036, 7, 1);
    config.annual_contribution = 50_000;

    const comparison: benchmark.BenchmarkComparison = .{
        .stock_returns = .{},
        .bond_returns = .{},
        .benchmark_returns = .{},
        .portfolio_returns = .{},
        .conservative_return = 0.07,
        .stock_pct = 0.75,
        .bond_pct = 0.25,
    };

    const ctx = try buildProjectionContext(
        arena.allocator(),
        config,
        comparison,
        0.75,
        0.25,
        1_000_000,
        &.{},
        Date.fromYmd(2026, 7, 1),
    );

    try std.testing.expectEqual(ProjectionInputs.target_retirement_date, ctx.inputs);
    try std.testing.expectEqual(@as(u16, 10), ctx.retirement.accumulation_years);
    try std.testing.expect(ctx.accumulation != null);
    // Median at retirement should exceed starting value (we
    // accumulate 10 years of $50k contributions on top).
    try std.testing.expect(ctx.accumulation.?.median_at_retirement > 1_000_000);
    try std.testing.expect(ctx.earliest == null);
}

test "buildProjectionContext: target_spending inputs promote a cell into retirement + accumulation" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var config = projections.UserConfig{};
    config.target_spending = 40_000;

    const comparison: benchmark.BenchmarkComparison = .{
        .stock_returns = .{},
        .bond_returns = .{},
        .benchmark_returns = .{},
        .portfolio_returns = .{},
        .conservative_return = 0.07,
        .stock_pct = 0.75,
        .bond_pct = 0.25,
    };

    const ctx = try buildProjectionContext(
        arena.allocator(),
        config,
        comparison,
        0.75,
        0.25,
        1_000_000,
        &.{},
        Date.fromYmd(2026, 5, 12),
    );

    try std.testing.expectEqual(ProjectionInputs.target_spending, ctx.inputs);
    try std.testing.expect(ctx.earliest != null);
    // 3 horizons × 3 confidence = 9 cells.
    try std.testing.expectEqual(@as(usize, 9), ctx.earliest.?.len);
    // Promotion runs: with $1M and $40k target, this is feasible
    // immediately or very early. Either way, retirement.source
    // should be .promoted and accumulation_stats should be present.
    try std.testing.expect(ctx.retirement.source == .promoted);
    try std.testing.expect(ctx.accumulation != null);
}

test "buildProjectionContext: both_targets inputs when both fields configured" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var config = projections.UserConfig{};
    config.retirement_at = Date.fromYmd(2036, 7, 1);
    config.target_spending = 40_000;

    const comparison: benchmark.BenchmarkComparison = .{
        .stock_returns = .{},
        .bond_returns = .{},
        .benchmark_returns = .{},
        .portfolio_returns = .{},
        .conservative_return = 0.07,
        .stock_pct = 0.75,
        .bond_pct = 0.25,
    };

    const ctx = try buildProjectionContext(
        arena.allocator(),
        config,
        comparison,
        0.75,
        0.25,
        1_000_000,
        &.{},
        Date.fromYmd(2026, 7, 1),
    );

    try std.testing.expectEqual(ProjectionInputs.both_targets, ctx.inputs);
    try std.testing.expect(ctx.accumulation != null);
    try std.testing.expect(ctx.earliest != null);
}

// ── Overlay-actuals tests ─────────────────────────────────────

/// Build a TimelinePoint with just the date and liquid value
/// — the overlay only reads those two fields. Empty accounts /
/// tax_types are fine; we never deinit these synthetic points.
fn makeTp(date: Date, liquid: f64) timeline.TimelinePoint {
    return .{
        .as_of_date = date,
        .net_worth = liquid,
        .liquid = liquid,
        .illiquid = 0,
        .accounts = &.{},
        .tax_types = &.{},
        .source = .snapshot,
    };
}

test "buildOverlayActuals: empty input produces empty section" {
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &.{},
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2025, 1, 1),
    );
    defer section.deinit();
    try std.testing.expectEqual(@as(usize, 0), section.points.len);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01);
}

test "buildOverlayActuals: single point at as_of has years=0" {
    const points = [_]timeline.TimelinePoint{
        makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
    };
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &points,
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2024, 1, 1),
    );
    defer section.deinit();
    try std.testing.expectEqual(@as(usize, 1), section.points.len);
    try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of);
    try std.testing.expectEqual(@as(f64, 1_000_000), section.points[0].liquid);
    try std.testing.expectEqual(@as(f64, 0.0), section.today_years);
}

test "buildOverlayActuals: filters out points before as_of and after today" {
    const points = [_]timeline.TimelinePoint{
        makeTp(Date.fromYmd(2023, 6, 1), 800_000), // before as_of - excluded
        makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
        makeTp(Date.fromYmd(2024, 7, 1), 1_100_000),
        makeTp(Date.fromYmd(2025, 1, 1), 1_200_000),
        makeTp(Date.fromYmd(2025, 6, 1), 1_300_000), // after today - excluded
    };
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &points,
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2025, 1, 1),
    );
    defer section.deinit();
    try std.testing.expectEqual(@as(usize, 3), section.points.len);
    // First point is at as_of itself, last point is at today.
    try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.points[2].years_from_as_of, 0.01);
}

test "buildOverlayActuals: years_from_as_of math is monotonic" {
    const points = [_]timeline.TimelinePoint{
        makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
        makeTp(Date.fromYmd(2024, 7, 1), 1_100_000),
        makeTp(Date.fromYmd(2025, 1, 1), 1_200_000),
    };
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &points,
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2025, 1, 1),
    );
    defer section.deinit();
    var prev: f64 = -1.0;
    for (section.points) |p| {
        try std.testing.expect(p.years_from_as_of > prev);
        prev = p.years_from_as_of;
    }
}

test "buildOverlayActuals: today_years matches today - as_of" {
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &.{},
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2026, 1, 1),
    );
    defer section.deinit();
    // Two calendar years = 731 days / 365.25 ≈ 2.001 years.
    try std.testing.expectApproxEqAbs(@as(f64, 2.0), section.today_years, 0.01);
}

test "buildOverlayActuals: liquid value is preserved verbatim" {
    const points = [_]timeline.TimelinePoint{
        makeTp(Date.fromYmd(2024, 6, 15), 1_234_567.89),
    };
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &points,
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2025, 1, 1),
    );
    defer section.deinit();
    try std.testing.expectEqual(@as(f64, 1_234_567.89), section.points[0].liquid);
}

test "buildOverlayActuals: empty range (today < as_of) produces empty points" {
    const points = [_]timeline.TimelinePoint{
        makeTp(Date.fromYmd(2024, 6, 1), 1_000_000),
    };
    var section = try buildOverlayActuals(
        std.testing.allocator,
        &points,
        Date.fromYmd(2025, 1, 1),
        Date.fromYmd(2024, 1, 1), // today before as_of (degenerate)
    );
    defer section.deinit();
    // Filter is `>= as_of AND <= today`. With today < as_of, no points
    // can satisfy both — section is empty.
    try std.testing.expectEqual(@as(usize, 0), section.points.len);
}

// ── Forecast-vs-actual view-model tests ───────────────────────

test "convergenceLines: empty input yields title + 'no data' message" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const lines = try convergenceLines(arena.allocator(), &.{});
    try std.testing.expect(lines.len >= 2);
    try std.testing.expect(lines[0].bold);
    try std.testing.expectEqual(StyleIntent.accent, lines[0].intent);
    // Second line is the muted "no data" caption.
    try std.testing.expectEqual(StyleIntent.muted, lines[1].intent);
}

test "convergenceLines: header constants drive aligned widths" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    const points = [_]forecast.ConvergencePoint{
        .{
            .observation_date = Date.fromYmd(2020, 1, 1),
            .projected_date = Date.fromYmd(2030, 6, 15),
            .years_until_retirement = 10.45,
            .reached = false,
        },
        .{
            .observation_date = Date.fromYmd(2025, 1, 1),
            .projected_date = Date.fromYmd(2025, 1, 1),
            .years_until_retirement = 0.0,
            .reached = true,
        },
    };
    const lines = try convergenceLines(arena.allocator(), &points);
    // Find the column-header line (look for "Observed").
    var header_idx: ?usize = null;
    for (lines, 0..) |ln, i| {
        if (std.mem.indexOf(u8, ln.text, "Observed") != null) header_idx = i;
    }
    try std.testing.expect(header_idx != null);
    const sep = lines[header_idx.? + 1].text;
    // Separator is built from the same comptime widths as the header,
    // so dash count must match the header's column extent.
    try std.testing.expect(std.mem.indexOf(u8, sep, "------------") != null);
}

test "backtestLines: emits color-coded legend for the chart series" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2020, 1, 1),
            .expected = 0.08,
            .realized_1y = 0.10,
            .realized_3y = 0.12,
            .realized_5y = null,
        },
    };
    const lines = try backtestLines(arena.allocator(), &anchors, false);

    // Legend lines: each in a distinct intent so renderers can map
    // line → series color. Counting matters because the chart
    // renderer reads colors by intent and a missing legend line
    // would silently break the user's "what is each color?"
    // mental model.
    var saw_accent = false;
    var saw_info = false;
    var saw_warning = false;
    var saw_positive = false;
    for (lines) |ln| {
        if (ln.bold) continue; // skip title
        if (std.mem.indexOf(u8, ln.text, "Expected (solid)") != null) {
            try std.testing.expectEqual(StyleIntent.accent, ln.intent);
            saw_accent = true;
        }
        if (std.mem.indexOf(u8, ln.text, "Realized 1y (dotted)") != null) {
            try std.testing.expectEqual(StyleIntent.info, ln.intent);
            saw_info = true;
        }
        if (std.mem.indexOf(u8, ln.text, "Realized 3y (dashed)") != null) {
            try std.testing.expectEqual(StyleIntent.warning, ln.intent);
            saw_warning = true;
        }
        if (std.mem.indexOf(u8, ln.text, "Realized 5y (solid)") != null) {
            try std.testing.expectEqual(StyleIntent.positive, ln.intent);
            saw_positive = true;
        }
    }
    try std.testing.expect(saw_accent);
    try std.testing.expect(saw_info);
    try std.testing.expect(saw_warning);
    try std.testing.expect(saw_positive);
}

test "backtestLines: real_mode emits inflation-deflated caption" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2020, 1, 1),
            .expected = 0.08,
            .realized_1y = 0.10,
            .realized_3y = null,
            .realized_5y = null,
        },
    };

    const nominal = try backtestLines(arena.allocator(), &anchors, false);
    var saw_nominal = false;
    for (nominal) |ln| {
        if (std.mem.indexOf(u8, ln.text, "inflation-deflated") != null) saw_nominal = true;
    }
    try std.testing.expect(!saw_nominal);

    const real = try backtestLines(arena.allocator(), &anchors, true);
    var saw_real = false;
    for (real) |ln| {
        if (std.mem.indexOf(u8, ln.text, "inflation-deflated") != null) saw_real = true;
    }
    try std.testing.expect(saw_real);
}

test "backtestLines: missing horizons render as em-dash" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2024, 6, 1), // recent → realized_5y missing
            .expected = 0.08,
            .realized_1y = 0.10,
            .realized_3y = null,
            .realized_5y = null,
        },
    };
    const lines = try backtestLines(arena.allocator(), &anchors, false);
    // The data row contains the anchor date AND the formatted
    // expected percentage. Use the latter to disambiguate from
    // the range-header line which also carries the date.
    for (lines) |ln| {
        if (std.mem.indexOf(u8, ln.text, "8.00%") != null and
            std.mem.indexOf(u8, ln.text, "2024-06-01") != null)
        {
            try std.testing.expect(std.mem.indexOf(u8, ln.text, "—") != null);
            return;
        }
    }
    try std.testing.expect(false); // data row not found
}

test "backtestLines: data rows align across mixed numeric/em-dash cells" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    // Two anchors: one fully populated, one with most horizons
    // missing. Em-dashes are 3 bytes / 1 display col; missing
    // cells use the hard-coded `dash_cell` literal pre-shaped to
    // 10 display columns. Numeric cells are pure ASCII so byte-
    // padding via `{s:>10}` is safe. Both rows must produce
    // identical display widths.
    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2014, 1, 1),
            .expected = 0.12,
            .realized_1y = 0.10,
            .realized_3y = 0.09,
            .realized_5y = 0.08,
        },
        .{
            .anchor_date = Date.fromYmd(2025, 6, 1),
            .expected = 0.07,
            .realized_1y = null,
            .realized_3y = null,
            .realized_5y = null,
        },
    };
    const lines = try backtestLines(arena.allocator(), &anchors, false);

    var full_row: ?[]const u8 = null;
    var sparse_row: ?[]const u8 = null;
    for (lines) |ln| {
        // Look for the data row signature: leading two spaces,
        // anchor date, no header / legend keywords.
        if (std.mem.indexOf(u8, ln.text, "2014-01-01") != null and
            std.mem.indexOf(u8, ln.text, "from") == null)
        {
            full_row = ln.text;
        }
        if (std.mem.indexOf(u8, ln.text, "2025-06-01") != null and
            std.mem.indexOf(u8, ln.text, "from") == null)
        {
            sparse_row = ln.text;
        }
    }
    try std.testing.expect(full_row != null);
    try std.testing.expect(sparse_row != null);
    // Both rows must occupy identical display-column widths
    // even though their byte lengths differ (each em-dash is 2
    // extra bytes vs a 1-byte ASCII space).
    try std.testing.expectEqual(fmt.displayCols(full_row.?), fmt.displayCols(sparse_row.?));
}

// ── Regression locks: back-test layout constants ──────────────
//
// The back-test table layout was tuned by eye. These tests
// freeze the exact widths and string contents so an "innocent"
// edit to the format string or a header constant trips a test
// instead of silently re-misaligning the table.

test "backtest layout: column widths are 11 cols / 12 cols" {
    try std.testing.expectEqual(@as(usize, 11), bt_col_value);
    try std.testing.expectEqual(@as(usize, 12), bt_col_anchor);
}

test "backtest layout: dash_cell is exactly 11 display columns" {
    try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(dash_cell));
}

test "backtest layout: dash_cell exact byte content" {
    // The dash position was tuned to read as visually centered
    // next to the right-aligned numeric data (which ends with
    // a trailing space — see `bufPrint("{d:.2}% ", ...)`). If
    // someone changes the dash position, this test fires and
    // forces a deliberate update of both the dash_cell literal
    // and the matching alignment test below.
    try std.testing.expectEqualStrings("      —    ", dash_cell);
}

test "backtest layout: all column headers are exactly 11 display cols" {
    try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_expected));
    try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_1y));
    try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_3y));
    try std.testing.expectEqual(@as(usize, 11), fmt.displayCols(bt_hdr_5y));
}

test "backtest layout: header strings exact byte content" {
    // Frozen against the user-tuned alignment. Edit these
    // strings only with a deliberate visual recheck of the
    // table output.
    try std.testing.expectEqualStrings(" Expected  ", bt_hdr_expected);
    try std.testing.expectEqualStrings("    1y     ", bt_hdr_1y);
    try std.testing.expectEqualStrings("    3y     ", bt_hdr_3y);
    try std.testing.expectEqualStrings("    5y     ", bt_hdr_5y);
}

test "backtest layout: data row em-dash sits under header centerline" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    // Single anchor with all realized values missing — every
    // numeric cell renders as `dash_cell`. Find the data row
    // and the header row, then verify the em-dash byte position
    // is consistent across all four numeric columns AND lines
    // up with the column header strings beneath the dashes.
    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2025, 6, 1),
            .expected = 0.07,
            .realized_1y = null,
            .realized_3y = null,
            .realized_5y = null,
        },
    };
    const lines = try backtestLines(arena.allocator(), &anchors, false);

    var data_row: ?[]const u8 = null;
    for (lines) |ln| {
        if (std.mem.indexOf(u8, ln.text, "2025-06-01") != null and
            std.mem.indexOf(u8, ln.text, "from") == null)
        {
            data_row = ln.text;
        }
    }
    try std.testing.expect(data_row != null);

    // Count em-dash occurrences in the data row. Each missing
    // realized horizon contributes exactly one — three total
    // (1y, 3y, 5y; expected is always populated).
    var dash_count: usize = 0;
    var i: usize = 0;
    while (i + 2 < data_row.?.len) : (i += 1) {
        if (std.mem.eql(u8, data_row.?[i .. i + 3], "—")) dash_count += 1;
    }
    try std.testing.expectEqual(@as(usize, 3), dash_count);
}

test "backtest layout: full-row width matches header-row width" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2014, 1, 1),
            .expected = 0.12,
            .realized_1y = 0.10,
            .realized_3y = 0.09,
            .realized_5y = 0.08,
        },
    };
    const lines = try backtestLines(arena.allocator(), &anchors, false);

    // Find the column header line ("Anchor … Expected … 1y …")
    // and the data row, then verify their display widths match.
    // A drift between header padding and data padding would
    // show up here as a mismatch.
    var header_row: ?[]const u8 = null;
    var data_row: ?[]const u8 = null;
    for (lines) |ln| {
        if (std.mem.indexOf(u8, ln.text, "Anchor") != null and
            std.mem.indexOf(u8, ln.text, "Expected") != null)
        {
            header_row = ln.text;
        }
        if (std.mem.indexOf(u8, ln.text, "2014-01-01") != null and
            std.mem.indexOf(u8, ln.text, "from") == null)
        {
            data_row = ln.text;
        }
    }
    try std.testing.expect(header_row != null);
    try std.testing.expect(data_row != null);
    try std.testing.expectEqual(fmt.displayCols(header_row.?), fmt.displayCols(data_row.?));
}

test "backtest layout: separator row width matches header width" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();

    const anchors = [_]forecast.BacktestAnchor{
        .{
            .anchor_date = Date.fromYmd(2020, 1, 1),
            .expected = 0.08,
            .realized_1y = 0.10,
            .realized_3y = null,
            .realized_5y = null,
        },
    };
    const lines = try backtestLines(arena.allocator(), &anchors, false);

    var header_row: ?[]const u8 = null;
    var sep_row: ?[]const u8 = null;
    for (lines) |ln| {
        if (std.mem.indexOf(u8, ln.text, "Anchor") != null and
            std.mem.indexOf(u8, ln.text, "Expected") != null)
        {
            header_row = ln.text;
        }
        // Separator row is all dashes after the leading 2 spaces.
        if (ln.text.len > 4 and
            std.mem.eql(u8, ln.text[0..4], "  --"))
        {
            sep_row = ln.text;
        }
    }
    try std.testing.expect(header_row != null);
    try std.testing.expect(sep_row != null);
    try std.testing.expectEqual(fmt.displayCols(header_row.?), fmt.displayCols(sep_row.?));
}
