/// Historical simulation engine for retirement projections.
///
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
/// historical dataset (1871–present), simulate a retirement of `horizon` years
/// using actual market returns, bond returns, and inflation. The portfolio is
/// rebalanced annually to the target stock/bond allocation.
///
/// Key outputs:
///   - Safe withdrawal amount at a given confidence level (binary search to $1)
///   - Success rate for a given spending level
///   - Percentile bands of portfolio value at each year (for charting)
const std = @import("std");
const builtin = @import("builtin");
const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig");
const srf = @import("srf");
const Date = @import("../Date.zig");

/// `log.warn` wrapper that no-ops under `zig build test`. Used for
/// validation warnings emitted while parsing user-supplied
/// `projections.srf` records: the test suite intentionally feeds
/// invalid inputs to `validRetirementTarget` and the config-loader
/// to verify they're rejected, but the resulting stderr noise
/// pollutes test output. Keep using `log.warn` directly when the
/// warning is interesting in tests too.
fn warnUser(comptime fmt: []const u8, args: anytype) void {
    if (builtin.is_test) return;
    log.warn(fmt, args);
}

// ── Life events ─────────────────────────────────────────────────

/// A resolved event ready for the simulation loop. All age-based timing
/// has been converted to simulation years. The simulation functions only
/// need this — no person indices, no ages array.
pub const ResolvedEvent = struct {
    start_year: u16,
    duration: u16, // 0 = permanent
    annual_amount: f64, // positive = income, negative = expense
    inflation_adjusted: bool,

    pub fn isActive(self: *const ResolvedEvent, y: u16) bool {
        if (y < self.start_year) return false;
        if (self.duration == 0) return true;
        return y < self.start_year + self.duration;
    }

    pub fn cashFlow(self: *const ResolvedEvent, y: u16, cumulative_inflation: f64) f64 {
        if (!self.isActive(y)) return 0;
        if (self.inflation_adjusted) return self.annual_amount * cumulative_inflation;
        return self.annual_amount;
    }
};

/// A discrete cash flow event that modifies the simulation's annual
/// withdrawal. Positive amount = income (reduces withdrawal, e.g.
/// Social Security). Negative = expense (increases withdrawal, e.g.
/// college tuition).
pub const LifeEvent = struct {
    name: [max_name_len]u8 = @splat(0),
    name_len: u8 = 0,
    start_age: u16,
    person: u8 = 0, // 0-indexed into birthdates array
    duration: u16 = 0, // 0 = permanent (until end of horizon)
    annual_amount: f64, // positive = income, negative = expense
    inflation_adjusted: bool = true,

    const max_name_len = 48;

    pub fn getName(self: *const LifeEvent) []const u8 {
        return self.name[0..self.name_len];
    }

    /// Simulation year when this event starts, given the persons' current ages.
    /// Returns null if the person index is out of range.
    pub fn startYear(self: *const LifeEvent, current_ages: []const u16) ?u16 {
        if (self.person >= current_ages.len) return null;
        const age = current_ages[self.person];
        if (self.start_age <= age) return 0;
        return self.start_age - age;
    }

    /// Is this event active in simulation year `y`?
    pub fn isActive(self: *const LifeEvent, y: u16, current_ages: []const u16) bool {
        const start = self.startYear(current_ages) orelse return false;
        if (y < start) return false;
        if (self.duration == 0) return true; // permanent
        return y < start + self.duration;
    }

    /// Cash flow contribution for simulation year `y`.
    /// Positive = income (reduces net withdrawal), negative = expense.
    pub fn cashFlow(self: *const LifeEvent, y: u16, cumulative_inflation: f64, current_ages: []const u16) f64 {
        if (!self.isActive(y, current_ages)) return 0;
        if (self.inflation_adjusted) return self.annual_amount * cumulative_inflation;
        return self.annual_amount;
    }

    /// Resolve this event into a ResolvedEvent using the given current ages.
    /// Returns null if the person index is out of range.
    pub fn resolve(self: *const LifeEvent, current_ages: []const u16) ?ResolvedEvent {
        const start = self.startYear(current_ages) orelse return null;
        return .{
            .start_year = start,
            .duration = self.duration,
            .annual_amount = self.annual_amount,
            .inflation_adjusted = self.inflation_adjusted,
        };
    }
};

// ── User configuration (from projections.srf) ──────────────────

/// Resolved retirement boundary, derived from `UserConfig` against a
/// reference date. The simulation consumes `accumulation_years` (an
/// integer), but the display layer renders the exact `date`.
pub const ResolvedRetirement = struct {
    /// Whole years of accumulation between today and the retirement
    /// date. The simulation runs in 1-year steps, so this is a
    /// floor — the displayed `date` is exact.
    accumulation_years: u16,
    /// Exact retirement date for display. `null` when `source ==
    /// .none` (no accumulation phase configured / already retired)
    /// or `.promoted_infeasible` (the earliest-retirement cell was
    /// selected but no accumulation length sustains the target
    /// spending at the promoted confidence).
    date: ?Date,
    source: enum {
        /// No retirement date configured. The line renders "none".
        none,
        /// User configured `retirement_at::DATE` directly.
        at_date,
        /// User configured `retirement_age:num:N`, resolved against
        /// the oldest birthdate.
        at_age,
        /// User configured `target_spending` only. The retirement
        /// line shows the promoted cell's date (the headline pick
        /// from the earliest-retirement grid).
        promoted,
        /// Same as `.promoted` but the selected cell returned no
        /// feasible accumulation_years from `findEarliestRetirement`.
        /// The line renders "not feasible" instead of a date.
        promoted_infeasible,
    },
};

/// User-configurable projection parameters, loaded from projections.srf.
///
/// Example projections.srf (union-tagged SRF records):
///   #!srfv1
///   type::config,target_stock_pct:num:77
///   type::config,horizon:num:30
///   type::config,horizon_age:num:90           # resolves to (90 − oldest current age)
///   type::birthdate,date::1975-03-15
///   type::birthdate,date::1978-06-22,person:num:2
///   type::event,name::Social Security,start_age:num:67,amount:num:38400
pub const UserConfig = struct {
    /// Target stock allocation percentage (0-100). Used for simulation blending.
    target_stock_pct: ?f64 = null,
    /// Annual fund expense ratio as a percentage (e.g. 0.18 = 0.18%),
    /// applied as a drag on the blended return each simulated year.
    /// **Defaults to 0.18%** -- FIRECalc's default and a
    /// realistic, mildly conservative figure for a fund-holding
    /// portfolio (modeling no fee at all is less accurate and makes the
    /// projection too optimistic). Override via
    /// `type::config,expense_ratio:num:0.04` for a pure low-cost index
    /// portfolio, a higher value for active funds, or `0` for an
    /// all-individual-stock portfolio. Stored as a percentage here
    /// (like `target_stock_pct`); converted to the decimal the
    /// simulation wants (`/100`) at the view boundary.
    expense_ratio: f64 = 0.18,
    /// Retirement horizons to simulate (years). Defaults to 20,30,45.
    horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)),
    horizon_count: u8 = 3,
    /// Per-horizon `retirement_target` annotation (90/95/99 confidence
    /// percentage, or 0 = no annotation). Parallel to `horizons`. At
    /// most one horizon may carry a non-zero value; when more than
    /// one is configured, all annotations are dropped (validation
    /// failure → fall back to the default promotion rule).
    ///
    /// Used by the target-spending input to pick which (horizon,
    /// confidence) cell from the Earliest retirement grid to
    /// promote into the Accumulation phase block. See
    /// `pickPromotedCell` for the resolution algorithm.
    horizon_targets: [max_horizons]u8 = @splat(0),
    /// Age-based horizon targets. Resolved at context-load time to
    /// `target_age − max(currentAges())` years — i.e. how long until the
    /// oldest configured person hits `target_age`. Rationale: the first
    /// person to hit the target age sets the meaningful planning horizon,
    /// because spending typically drops substantially after the first death.
    horizon_ages: [max_horizons]u16 = @splat(0),
    /// Per-`horizon_age` `retirement_target` annotation, parallel to
    /// `horizon_ages`. Carried through to the resolved `horizon_targets`
    /// slot when `resolveHorizonAges` appends the resolved year count.
    horizon_age_targets: [max_horizons]u8 = @splat(0),
    horizon_age_count: u8 = 0,
    /// Confidence levels for safe withdrawal. Always 90/95/99.
    confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.99 },
    /// Birthdates for age-based event timing.
    birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)),
    birthdate_count: u8 = 0,
    /// Life events (income/expenses) that modify annual cash flow.
    // SAFETY: paired with `event_count`; only `events[0..event_count]` is read.
    events: [max_events]LifeEvent = undefined,
    event_count: u8 = 0,
    // ── Accumulation phase ──────────────────────────────────────
    /// Target retirement age for the oldest configured person. The
    /// retirement date is the day they turn this age (clamping Feb 29
    /// to Feb 28 in non-leap target years). Mutually exclusive with
    /// `retirement_at`; if both are set, `retirement_at` wins.
    retirement_age: ?u16 = null,
    /// Absolute retirement date. Wins over `retirement_age` when both
    /// are set.
    retirement_at: ?Date = null,
    /// Total household contributions per year, in today's dollars.
    /// Defaults to zero (distribution-only behavior).
    annual_contribution: f64 = 0,
    /// If true, contributions grow with CPI year-over-year (modeling
    /// a constant percentage of CPI-tracked income). If false,
    /// contributions are nominal.
    contribution_inflation_adjusted: bool = true,
    /// Target annual spending in today's dollars. When set, the
    /// projections command will search for the earliest retirement
    /// date at which this spending level is sustainable.
    target_spending: ?f64 = null,
    /// If true, the target spending grows with CPI during the
    /// distribution phase (matches the existing SWR model).
    target_spending_inflation_adjusted: bool = true,
    /// Stock benchmark symbol used in the projection's
    /// benchmark-comparison table and bands. Defaults to "SPY".
    /// Override via `type::config,benchmark_stock::SYMBOL` in
    /// `projections.srf`. The slice points into
    /// `benchmark_stock_buf` when overridden, or into a string
    /// literal in the binary's read-only data segment for the
    /// default — either way, valid for the lifetime of the
    /// `UserConfig`.
    benchmark_stock: []const u8 = "SPY",
    /// Backing buffer for an overridden `benchmark_stock`. Untouched
    /// (and unread) when the default is in effect. Sized to fit
    /// reasonable ticker lengths.
    // SAFETY: only read when `benchmark_stock` points into this buffer
    // (i.e. when the user has overridden the default); otherwise the
    // backing slice points at a literal and this buffer is unobserved.
    benchmark_stock_buf: [16]u8 = undefined,
    /// Bond benchmark symbol. Same lifetime / override mechanics
    /// as `benchmark_stock`.
    benchmark_bond: []const u8 = "AGG",
    // SAFETY: same override-only read pattern as `benchmark_stock_buf`.
    benchmark_bond_buf: [16]u8 = undefined,

    const max_horizons: usize = 8;
    const max_persons: usize = 4;
    pub const max_events: usize = 16;

    /// Errors that can arise when resolving age-based horizons.
    pub const ResolveError = error{
        /// `type::config,horizon_age:num:N` was specified in projections.srf
        /// but no `type::birthdate` record exists to anchor the calculation.
        HorizonAgeWithoutBirthdate,
    };

    pub fn getHorizons(self: *const UserConfig) []const u16 {
        return self.horizons[0..self.horizon_count];
    }

    pub fn getConfidenceLevels(self: *const UserConfig) []const f64 {
        return &self.confidence_levels;
    }

    pub fn getEvents(self: *const UserConfig) []const LifeEvent {
        return self.events[0..self.event_count];
    }

    /// Compute ages (in whole years) as of `as_of`. Pass today's date
    /// for "current ages"; pass a historical date for backfill.
    pub fn currentAges(self: *const UserConfig, as_of: Date) [max_persons]u16 {
        var ages: [max_persons]u16 = @splat(0);
        for (0..self.birthdate_count) |i| {
            ages[i] = Date.wholeYearsBetween(self.birthdates[i], as_of);
        }
        return ages;
    }

    /// Resolve age-based horizons (`horizon_ages`) into year counts and
    /// append them to `horizons`. For each target age, computes
    /// `target_age − max(currentAges(as_of))` — the number of years
    /// until the oldest configured person hits that age. Targets that are
    /// already in the past (oldest age ≥ target) are silently skipped.
    ///
    /// Errors if `horizon_ages` is non-empty but no birthdate is configured.
    /// Safe to call multiple times; subsequent calls are no-ops because
    /// `horizon_age_count` is cleared after resolution.
    pub fn resolveHorizonAges(self: *UserConfig, as_of: Date) ResolveError!void {
        if (self.horizon_age_count == 0) return;
        if (self.birthdate_count == 0) return error.HorizonAgeWithoutBirthdate;

        const oldest = self.oldestAge(as_of);

        for (0..self.horizon_age_count) |i| {
            const target = self.horizon_ages[i];
            if (target <= oldest) continue; // already past target, skip silently
            const years: u16 = target - oldest;
            if (self.horizon_count < max_horizons) {
                self.horizons[self.horizon_count] = years;
                // Carry through any retirement_target annotation from
                // the source horizon_age record.
                self.horizon_targets[self.horizon_count] = self.horizon_age_targets[i];
                self.horizon_count += 1;
            }
        }
        // Clear so a second call is a no-op.
        self.horizon_age_count = 0;
    }

    /// Resolve age-based horizons using today's date. Convenience wrapper
    /// around `resolveHorizonAges`.
    pub fn resolveHorizonAgesNow(self: *UserConfig) ResolveError!void {
        return self.resolveHorizonAges(Date.fromEpoch(std.time.timestamp()));
    }

    /// Sum all event cash flows for simulation year `y`.
    pub fn eventNetCashFlow(self: *const UserConfig, y: u16, cumulative_inflation: f64, current_ages: []const u16) f64 {
        var total: f64 = 0;
        for (self.events[0..self.event_count]) |*ev| {
            total += ev.cashFlow(y, cumulative_inflation, current_ages);
        }
        return total;
    }

    /// Resolve all events into ResolvedEvents for the simulation.
    /// Skips events with invalid person indices.
    pub fn resolveEvents(self: *const UserConfig, as_of: Date) [max_events]ResolvedEvent {
        const ages = self.currentAges(as_of);
        return resolveEventsWithAges(self, &ages);
    }

    /// Resolve all events using pre-computed ages (for testing).
    pub fn resolveEventsWithAges(self: *const UserConfig, ages: []const u16) [max_events]ResolvedEvent {
        var resolved: [max_events]ResolvedEvent = undefined;
        for (self.events[0..self.event_count], 0..) |*ev, i| {
            resolved[i] = ev.resolve(ages) orelse .{
                .start_year = std.math.maxInt(u16), // effectively never active
                .duration = 0,
                .annual_amount = 0,
                .inflation_adjusted = true,
            };
        }
        return resolved;
    }

    /// Resolve the configured retirement boundary against `as_of`.
    /// Returns the integer accumulation_years used by the simulation,
    /// the displayed exact date, and the resolution source.
    ///
    /// `as_of` is the reference date — pass today's date for live
    /// mode, or a historical snapshot date when re-running the
    /// projection against past data. The function works correctly
    /// for any reference date.
    ///
    /// Resolution rules:
    ///   - `retirement_at` set and not in the past (relative to
    ///     `as_of`) → that date.
    ///   - `retirement_age` set, with at least one birthdate, and
    ///     the oldest person hasn't already passed that age as of
    ///     `as_of` → the date that person turns the target age
    ///     (clamping Feb 29 to Feb 28 in non-leap target years).
    ///   - Otherwise → `.none`. accumulation_years = 0.
    ///
    /// `retirement_at` wins when both are set.
    pub fn resolveRetirement(self: *const UserConfig, as_of: Date) ResolvedRetirement {
        if (self.retirement_at) |d| {
            if (d.lessThan(as_of)) return .{
                .accumulation_years = 0,
                .date = null,
                .source = .none,
            };
            return .{
                .accumulation_years = Date.wholeYearsBetween(as_of, d),
                .date = d,
                .source = .at_date,
            };
        }
        if (self.retirement_age) |target_age| {
            const oldest_bd = self.oldestBirthdate() orelse return .{
                .accumulation_years = 0,
                .date = null,
                .source = .none,
            };
            const ret_date = oldest_bd.addYears(target_age);
            if (ret_date.lessThan(as_of)) return .{
                .accumulation_years = 0,
                .date = null,
                .source = .none,
            };
            return .{
                .accumulation_years = Date.wholeYearsBetween(as_of, ret_date),
                .date = ret_date,
                .source = .at_age,
            };
        }
        return .{ .accumulation_years = 0, .date = null, .source = .none };
    }

    /// Find the birthdate of the oldest configured person — the
    /// earliest date in `birthdates[]`. Returns null if no
    /// birthdates are configured.
    ///
    /// Used by `resolveRetirement` (with `retirement_age`),
    /// `resolveHorizonAges`, and `pickPromotedCell` to anchor any
    /// "oldest person" computation against a single source of
    /// truth. Pair with `Date.wholeYearsBetween(oldest, as_of)` for
    /// "oldest person's age right now"; that's also packaged as
    /// `oldestAge(as_of)` for caller convenience.
    pub fn oldestBirthdate(self: *const UserConfig) ?Date {
        if (self.birthdate_count == 0) return null;
        var oldest = self.birthdates[0];
        var i: u8 = 1;
        while (i < self.birthdate_count) : (i += 1) {
            if (self.birthdates[i].lessThan(oldest)) oldest = self.birthdates[i];
        }
        return oldest;
    }

    /// Age (whole years) of the oldest configured person as of
    /// `as_of`. Returns 0 when no birthdates are configured (which
    /// callers should treat as "no person to age out" rather than
    /// "person aged zero").
    pub fn oldestAge(self: *const UserConfig, as_of: Date) u16 {
        const oldest = self.oldestBirthdate() orelse return 0;
        return Date.wholeYearsBetween(oldest, as_of);
    }
};

// ── SRF parse types (private) ───────────────────────────────────

const SrfConfig = struct {
    type: []const u8 = "",
    target_stock_pct: ?f64 = null,
    expense_ratio: ?f64 = null,
    horizon: ?u16 = null,
    horizon_age: ?u16 = null,
    /// Earliest-retirement promotion override: when paired with
    /// `horizon` or `horizon_age`, marks that horizon as the one to
    /// use for the promoted retirement-line cell. Allowed values:
    /// 90, 95, 99.
    /// Anything else is rejected at parse time.
    retirement_target: ?u8 = null,
    retirement_age: ?u16 = null,
    retirement_at: ?Date = null,
    annual_contribution: ?f64 = null,
    contribution_inflation_adjusted: ?bool = null,
    target_spending: ?f64 = null,
    target_spending_inflation_adjusted: ?bool = null,
    benchmark_stock: ?[]const u8 = null,
    benchmark_bond: ?[]const u8 = null,
};

const SrfBirthdate = struct {
    type: []const u8 = "",
    date: Date,
    person: ?u8 = null, // 1-indexed in SRF; null = sequential
};

const SrfEvent = struct {
    type: []const u8 = "",
    name: []const u8 = "",
    start_age: u16 = 0,
    person: u8 = 1, // 1-indexed in SRF
    duration: u16 = 0,
    amount: f64 = 0,
    inflation_adjusted: bool = true,
};

const SrfProjection = union(enum) {
    pub const srf_tag_field = "type";
    config: SrfConfig,
    birthdate: SrfBirthdate,
    event: SrfEvent,
};

/// Parse a projections.srf file into a UserConfig.
/// Returns default config if data is null or unparseable.
///
/// Uses an internal stack-backed FixedBufferAllocator for the SRF
/// iterator's scratch. The default `parse_allocator` keeps short
/// string values borrowing from `data` (no copy) and transparently
/// allocates from the iterator's fallback arena for any
/// multi-line/binary values (e.g. an event `name` containing a
/// comma, which `srf.fmt` encodes with a length prefix). The 8 KB
/// buffer comfortably fits any realistic projections.srf — a
/// handful of config + birthdate + event records. On overflow the
/// parse aborts and we return the default config, matching the
/// existing "unparseable → defaults" contract.
///
/// Format (union-tagged SRF records):
///   type::config,target_stock_pct:num:80
///   type::config,horizon:num:30
///   type::birthdate,date::1975-03-15
///   type::event,name::Social Security,start_age:num:67,amount:num:38400
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
    var config = UserConfig{};
    const raw = data orelse return config;
    if (raw.len == 0) return config;

    var scratch_buf: [8 * 1024]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&scratch_buf);
    const scratch = fba.allocator();

    var reader = std.Io.Reader.fixed(raw);
    var it = srf.iterator(&reader, scratch, .{ .parse_allocator = .none }) catch return config;
    defer it.deinit();

    var saw_horizon = false;
    var birthdate_seq: u8 = 0;
    // Count of valid `retirement_target` annotations seen during
    // parse (across both `horizon` and `horizon_age` records). More
    // than one is a configuration error — we'll drop them all
    // post-loop and let `pickPromotedCell` fall back to the default
    // rule. A single bad value (not in {90,95,99}) is treated as
    // "no annotation on this record" and doesn't poison the others.
    var annotation_count: u8 = 0;

    while (it.next() catch null) |field_it| {
        const rec = field_it.to(SrfProjection, .{}) catch continue;
        switch (rec) {
            .config => |c| {
                config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct;
                config.expense_ratio = c.expense_ratio orelse config.expense_ratio;
                if (c.horizon) |h| {
                    if (!saw_horizon) {
                        config.horizon_count = 0;
                        saw_horizon = true;
                    }
                    if (h == 0) {
                        log.warn("projections: horizon must be > 0; ignoring record", .{});
                    } else if (config.horizon_count >= UserConfig.max_horizons) {
                        log.warn("projections: horizon limit reached ({d}); ignoring extra horizon record (value {d})", .{ UserConfig.max_horizons, h });
                    } else {
                        config.horizons[config.horizon_count] = h;
                        if (validRetirementTarget(c.retirement_target)) |conf| {
                            config.horizon_targets[config.horizon_count] = conf;
                            annotation_count += 1;
                        }
                        config.horizon_count += 1;
                    }
                }
                if (c.horizon_age) |age| {
                    // Age-based horizons are stored raw and resolved later
                    // via `UserConfig.resolveHorizonAges(as_of)` once the
                    // view layer knows the projection date. They also count
                    // as "saw_horizon" so a file containing only
                    // `horizon_age` records replaces the default {20,30,45}
                    // once resolved.
                    if (!saw_horizon) {
                        config.horizon_count = 0;
                        saw_horizon = true;
                    }
                    if (age == 0) {
                        log.warn("projections: horizon_age must be > 0; ignoring record", .{});
                    } else if (config.horizon_age_count >= UserConfig.max_horizons) {
                        log.warn("projections: horizon_age limit reached ({d}); ignoring extra horizon_age record (value {d})", .{ UserConfig.max_horizons, age });
                    } else {
                        config.horizon_ages[config.horizon_age_count] = age;
                        if (validRetirementTarget(c.retirement_target)) |conf| {
                            config.horizon_age_targets[config.horizon_age_count] = conf;
                            annotation_count += 1;
                        }
                        config.horizon_age_count += 1;
                    }
                }
                config.retirement_age = c.retirement_age orelse config.retirement_age;
                config.retirement_at = c.retirement_at orelse config.retirement_at;
                if (c.annual_contribution) |amt| {
                    // Negative values are nonsensical (a contribution
                    // is income into the portfolio); drop the record.
                    if (amt >= 0) {
                        config.annual_contribution = amt;
                    } else {
                        warnUser("projections: annual_contribution must be >= 0 (got {d}); ignoring record", .{amt});
                    }
                }
                if (c.contribution_inflation_adjusted) |b| {
                    config.contribution_inflation_adjusted = b;
                }
                if (c.target_spending) |amt| {
                    if (amt >= 0) {
                        config.target_spending = amt;
                    } else {
                        warnUser("projections: target_spending must be >= 0 (got {d}); ignoring record", .{amt});
                    }
                }
                if (c.target_spending_inflation_adjusted) |b| {
                    config.target_spending_inflation_adjusted = b;
                }
                if (c.benchmark_stock) |sym| {
                    if (sym.len == 0 or sym.len > config.benchmark_stock_buf.len) {
                        warnUser("projections: benchmark_stock must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_stock_buf.len, sym.len });
                    } else {
                        // Dupe into our own buffer so the slice
                        // outlives the SRF iterator's backing data.
                        @memcpy(config.benchmark_stock_buf[0..sym.len], sym);
                        config.benchmark_stock = config.benchmark_stock_buf[0..sym.len];
                    }
                }
                if (c.benchmark_bond) |sym| {
                    if (sym.len == 0 or sym.len > config.benchmark_bond_buf.len) {
                        warnUser("projections: benchmark_bond must be 1..{d} chars (got {d}); ignoring record", .{ config.benchmark_bond_buf.len, sym.len });
                    } else {
                        @memcpy(config.benchmark_bond_buf[0..sym.len], sym);
                        config.benchmark_bond = config.benchmark_bond_buf[0..sym.len];
                    }
                }
            },
            .birthdate => |b| {
                // person is 1-indexed in SRF; convert to 0-indexed.
                // If not specified, assign sequentially.
                const idx: u8 = if (b.person) |p| p -| 1 else birthdate_seq;
                if (idx < UserConfig.max_persons) {
                    config.birthdates[idx] = b.date;
                    if (idx >= config.birthdate_count) config.birthdate_count = idx + 1;
                } else {
                    log.warn("projections: birthdate person index {d} exceeds limit ({d}); ignoring record", .{ idx + 1, UserConfig.max_persons });
                }
                birthdate_seq += 1;
            },
            .event => |e| {
                if (e.start_age == 0) {
                    log.warn("projections: event '{s}' has start_age 0; ignoring record", .{e.name});
                } else if (config.event_count >= UserConfig.max_events) {
                    log.warn("projections: event limit reached ({d}); ignoring extra event '{s}'", .{ UserConfig.max_events, e.name });
                } else {
                    var ev = LifeEvent{
                        .start_age = e.start_age,
                        .person = e.person -| 1, // 1-indexed → 0-indexed
                        .duration = e.duration,
                        .annual_amount = e.amount,
                        .inflation_adjusted = e.inflation_adjusted,
                    };
                    const len = @min(e.name.len, LifeEvent.max_name_len);
                    @memcpy(ev.name[0..len], e.name[0..len]);
                    ev.name_len = @intCast(len);
                    config.events[config.event_count] = ev;
                    config.event_count += 1;
                }
            },
        }
    }

    // Validation: at most one `retirement_target` annotation may be
    // present across all horizon and horizon_age records. If more
    // than one was seen, drop them all and let `pickPromotedCell`
    // fall back to the default rule. Logged as a warning so the
    // user knows their override was ignored.
    if (annotation_count > 1) {
        warnUser("projections: retirement_target set on multiple horizons; ignoring all annotations and using default promotion rule", .{});
        config.horizon_targets = @splat(0);
        config.horizon_age_targets = @splat(0);
    }

    return config;
}

/// Validate a `retirement_target` SRF value. Returns the value
/// unchanged if it's exactly 90, 95, or 99; returns null otherwise
/// (logged as a warning so the user notices the typo). Used at parse
/// time; the view-layer `pickPromotedCell` trusts whatever lands in
/// `horizon_targets`.
fn validRetirementTarget(raw: ?u8) ?u8 {
    const v = raw orelse return null;
    if (v == 90 or v == 95 or v == 99) return v;
    warnUser("projections: retirement_target must be 90, 95, or 99 (got {d}); annotation ignored", .{v});
    return null;
}

// ── Configuration ──────────────────────────────────────────────

/// Conservative return estimation defaults.
/// These are module-level constants that will eventually move to projections.srf.
pub const default_return_cap: ?f64 = null; // no cap currently
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y

// ── Results ────────────────────────────────────────────────────

pub const WithdrawalResult = struct {
    /// Confidence level (e.g. 0.99 = 99%).
    confidence: f64,
    /// Maximum annual withdrawal that achieves this confidence.
    annual_amount: f64,
    /// As a fraction of starting portfolio value.
    withdrawal_rate: f64,
};

pub const YearPercentiles = struct {
    /// Year offset from retirement start (0 = start, 1 = after year 1, etc.)
    year: u16,
    p10: f64,
    p25: f64,
    p50: f64,
    p75: f64,
    p90: f64,
};

// ── Core simulation ────────────────────────────────────────────

/// Parameters bundling the full two-phase simulation inputs. Used
/// internally by all simulation entry points so the same code path
/// handles both distribution-only (today's behavior, with
/// `accumulation_years == 0`) and accumulation-then-distribution.
pub const SimParams = struct {
    initial_value: f64,
    stock_pct: f64,
    annual_spending: f64,
    spending_inflation_adjusted: bool = true,
    /// Distribution-phase length (the "horizon" in the existing API).
    distribution_years: u16,
    accumulation_years: u16 = 0,
    annual_contribution: f64 = 0,
    contribution_inflation_adjusted: bool = true,
    /// Annual fund expense ratio (decimal, e.g. 0.0018 = 0.18%),
    /// subtracted from the blended market return each year in both
    /// phases. Defaults to 0 (no fee modeled). Mirrors FIRECalc's
    /// "investment expenses" drag; its default is 0.18%.
    expense_ratio: f64 = 0,
    events: []const ResolvedEvent = &.{},

    /// Total simulated path length (including year 0).
    pub fn totalYears(self: SimParams) u16 {
        return self.accumulation_years + self.distribution_years;
    }
};

/// Optional alternate market dataset, used by tests to inject a
/// synthetic constant-return / constant-CPI fixture. When `null`, the
/// global `shiller.annual_returns` is used.
const ShillerYearSlice = []const shiller.ShillerYear;

/// Maximum cycles available given a total horizon. Returns 0 if no
/// data covers the full horizon.
///
/// Counts every cohort whose full span fits in the data: a cohort
/// starting at index `i` reads `data[i .. i + total_years - 1]`, so
/// the valid starts are `0 .. data.len - total_years` inclusive,
/// i.e. `data.len - total_years + 1` cohorts. This matches FIRECalc's
/// convention ("1871, 1872, ... until the most recent year for which
/// there are results available") and `shiller.maxCycles`. Earlier
/// this returned `data.len - total_years`, which silently dropped the
/// single most-recent cohort (e.g. the 1996-2025 start for a 30-year
/// horizon) -- a complete, often-stressful sequence. See the FIRECalc
/// parity suite below.
fn maxCyclesFor(data: ShillerYearSlice, total_years: u16) usize {
    if (data.len < total_years) return 0;
    return data.len - total_years + 1;
}

/// Simulate a single cycle of the two-phase model:
///   1. Accumulation: contributions in, life events, market return,
///      CPI advance. No spending. Failure not counted.
///   2. Distribution: spending out, life events, market return, CPI
///      advance. Failure (portfolio ≤ 0) records and stops further
///      simulation, with subsequent years zeroed.
///
/// `buf` is optional. Pass a non-null buffer of length
/// `params.totalYears() + 1` when you need the full path: `buf[0]`
/// is the initial value; `buf[i]` for i ≥ 1 is the portfolio value
/// at the END of simulation year i; the retirement boundary is at
/// index `accumulation_years` (i.e. `buf[accumulation_years]` is
/// the portfolio at retirement, before the first withdrawal).
///
/// Pass `null` when you only need the survival verdict — the
/// function will return `false` as soon as it detects failure,
/// skipping the rest of the simulation and avoiding any buffer
/// writes. Saves work in the SWR binary-search inner loop where
/// `successRateParams` calls this thousands of times per search.
///
/// Returns true if the cycle survived the distribution phase.
fn simulateTwoPhase(
    buf: ?[]f64,
    data: ShillerYearSlice,
    start_index: usize,
    params: SimParams,
) bool {
    const total = params.totalYears();
    var portfolio = params.initial_value;
    if (buf) |b| b[0] = portfolio;

    var cumulative_inflation: f64 = 1.0;
    var failed = false;

    var y: usize = 0;
    while (y < total) : (y += 1) {
        const di = start_index + y;
        if (di >= data.len) {
            // Out of data — survived (or failed earlier and were
            // walking to end for the buffer fill). Path callers
            // get the tail filled with the last known value;
            // null-buf callers just return.
            if (buf) |b| {
                for (y + 1..@as(usize, total) + 1) |k| b[k] = portfolio;
            }
            return !failed;
        }

        const yr = data[di];
        const in_accumulation = y < params.accumulation_years;

        // Life events apply in both phases.
        var event_net: f64 = 0;
        for (params.events) |*ev| {
            event_net += ev.cashFlow(@intCast(y), cumulative_inflation);
        }

        if (in_accumulation) {
            const contribution = if (params.contribution_inflation_adjusted)
                params.annual_contribution * cumulative_inflation
            else
                params.annual_contribution;
            portfolio += contribution + event_net;
        } else {
            const spending = if (params.spending_inflation_adjusted)
                params.annual_spending * cumulative_inflation
            else
                params.annual_spending;
            portfolio -= spending - event_net;
            if (portfolio <= 0 and !failed) {
                // Survival-only callers exit immediately — there's
                // no path to fill, and the verdict is locked in.
                if (buf == null) return false;
                failed = true;
            }
        }

        // Market return on the post-cashflow balance, net of the
        // fund expense ratio (FIRECalc applies "investment expenses"
        // the same way). Skipped after failure (path callers have
        // already locked the verdict; remaining buf entries get
        // zeroed below).
        if (!failed) {
            const blended_return = params.stock_pct * yr.sp500_total_return +
                (1.0 - params.stock_pct) * yr.bond_total_return;
            portfolio *= (1.0 + blended_return - params.expense_ratio);
        }

        // Advance CPI for next year. (No-op for the verdict after
        // failure, but cheap and keeps the loop body uniform.)
        cumulative_inflation *= (1.0 + yr.cpi_inflation);

        if (buf) |b| b[y + 1] = if (failed) 0.0 else portfolio;
    }

    return !failed;
}

/// Simulate a single retirement cycle starting at `start_index` in the
/// Shiller dataset, lasting `horizon` years, with the given annual spending
/// (inflation-adjusted) and stock/bond allocation.
///
/// Distribution-only convenience wrapper around `simulateTwoPhase`.
/// Preserves the existing API; new accumulation-aware code paths use
/// `simulateTwoPhase` directly.
fn simulateCycle(
    buf: []f64,
    start_index: usize,
    horizon: u16,
    initial_value: f64,
    annual_spending: f64,
    stock_pct: f64,
    events: []const ResolvedEvent,
) void {
    _ = simulateTwoPhase(buf, shiller.annual_returns, start_index, .{
        .initial_value = initial_value,
        .stock_pct = stock_pct,
        .annual_spending = annual_spending,
        .distribution_years = horizon,
        .events = events,
    });
}

/// Run the full historical simulation: for each possible starting year,
/// simulate a retirement of `horizon` years. Returns the number of cycles
/// where the portfolio survived (never went to zero).
///
/// `all_paths` is a 2D buffer: [cycle_index][year] = portfolio value.
/// Must be pre-allocated with dimensions [num_cycles][horizon + 1].
fn runAllCycles(
    all_paths: [][]f64,
    horizon: u16,
    initial_value: f64,
    annual_spending: f64,
    stock_pct: f64,
    events: []const ResolvedEvent,
) usize {
    return runAllCyclesParams(all_paths, shiller.annual_returns, .{
        .initial_value = initial_value,
        .stock_pct = stock_pct,
        .annual_spending = annual_spending,
        .distribution_years = horizon,
        .events = events,
    });
}

/// Run all cycles with full SimParams — accumulation-aware variant
/// used by both the distribution-only wrapper above and the
/// earliest-retirement search (`findEarliestRetirement`).
fn runAllCyclesParams(
    all_paths: [][]f64,
    data: ShillerYearSlice,
    params: SimParams,
) usize {
    const num_cycles = maxCyclesFor(data, params.totalYears());
    var survived: usize = 0;
    for (0..num_cycles) |cycle| {
        if (simulateTwoPhase(all_paths[cycle], data, cycle, params)) survived += 1;
    }
    return survived;
}

fn successRateParams(data: ShillerYearSlice, params: SimParams) f64 {
    const num_cycles = maxCyclesFor(data, params.totalYears());
    if (num_cycles == 0) return 0.0;
    var survived: usize = 0;
    for (0..num_cycles) |cycle| {
        // `null` buffer → simulateTwoPhase exits as soon as a
        // failure is detected. Cheaper than collecting the full
        // path when we only need the survival verdict.
        if (simulateTwoPhase(null, data, cycle, params)) survived += 1;
    }
    return @as(f64, @floatFromInt(survived)) / @as(f64, @floatFromInt(num_cycles));
}

// ── Safe withdrawal search ─────────────────────────────────────

/// Two-phase variant of `findSafeWithdrawal`. Searches for the
/// largest `annual_spending` (in today's dollars) such that the
/// distribution-phase failure rate stays ≤ `1 - confidence`, with
/// `accumulation_years` of contributions feeding the portfolio first.
///
/// When `accumulation_years == 0`, contributions are zero, and
/// `expense_ratio == 0`, this reduces exactly to `findSafeWithdrawal`
/// (the equivalence is pinned by the `regression: zero accumulation
/// matches direct findSafeWithdrawal` test). Used as the inner search
/// for the target-retirement-date input when the user has configured
/// a non-zero accumulation phase.
pub fn findSafeWithdrawalWithAccumulation(
    horizon: u16,
    initial_value: f64,
    stock_pct: f64,
    confidence: f64,
    events: []const ResolvedEvent,
    accumulation_years: u16,
    annual_contribution: f64,
    contribution_inflation_adjusted: bool,
    expense_ratio: f64,
) WithdrawalResult {
    return searchSafeWithdrawal(.{
        .initial_value = initial_value,
        .stock_pct = stock_pct,
        .annual_spending = 0, // overwritten by the search loop
        .distribution_years = horizon,
        .accumulation_years = accumulation_years,
        .annual_contribution = annual_contribution,
        .contribution_inflation_adjusted = contribution_inflation_adjusted,
        .expense_ratio = expense_ratio,
        .events = events,
    }, confidence);
}

/// Unified safe-withdrawal search. Binary-searches `annual_spending`
/// over `[lo, hi]` to $1 precision, seeded with a 4%-rule estimate
/// against the projected post-accumulation portfolio value.
///
/// `base` carries every `SimParams` field except `annual_spending`,
/// which the search overwrites per probe. Both
/// `findSafeWithdrawal` (zero accumulation) and
/// `findSafeWithdrawalWithAccumulation` (non-zero accumulation)
/// delegate here.
///
/// Bracket seeding:
///   - When `accumulation_years == 0`, `projected_value ==
///     initial_value` so the seed and bracket reduce to the
///     classic 4%-rule starting point.
///   - When non-zero, `projected_value` is a rough estimate of the
///     post-accumulation portfolio (initial × 1.06^N + N ×
///     contribution). The bracket-widening below corrects for any
///     inaccuracy in the estimate.
fn searchSafeWithdrawal(base: SimParams, confidence: f64) WithdrawalResult {
    // Project the post-accumulation portfolio. For zero-accumulation
    // configs `pow(1.06, 0) == 1.0` so this collapses to
    // `initial_value` — same seed the original `findSafeWithdrawal`
    // used.
    const accum_growth_factor: f64 = std.math.pow(f64, 1.06, @as(f64, @floatFromInt(base.accumulation_years)));
    const projected_value = base.initial_value * accum_growth_factor +
        base.annual_contribution * @as(f64, @floatFromInt(base.accumulation_years));

    // Seed from the 4% rule, adjusted for horizon and confidence.
    // Base ~4% for 30yr/95%. Shorter horizons allow more; longer less.
    // Higher confidence requires less.
    const base_rate = 0.04;
    const horizon_adj = 30.0 / @as(f64, @floatFromInt(base.distribution_years));
    const conf_adj = (1.0 - confidence) / 0.05;
    const estimate = projected_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj);

    // Search band: ±50% of estimate. The lower clamp is 0; the
    // upper clamp ensures we don't start below the projected value
    // (relevant when the 4%-rule estimate undershoots a high
    // accumulation case).
    var lo: f64 = @max(estimate * 0.5, 0);
    var hi: f64 = @max(estimate * 1.5, projected_value);

    // Mutable probe — same struct, different `annual_spending` per
    // iteration. Avoids reconstructing SimParams on every probe.
    var probe = base;

    // Verify bounds bracket the answer; widen if not.
    probe.annual_spending = lo;
    if (successRateParams(shiller.annual_returns, probe) < confidence) {
        log.debug("searchSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence });
        lo = 0;
    }
    probe.annual_spending = hi;
    if (successRateParams(shiller.annual_returns, probe) >= confidence) {
        log.debug("searchSafeWithdrawal: estimate too low, widening hi (horizon={d}, conf={d:.2})", .{ base.distribution_years, confidence });
        hi = @max(projected_value, base.initial_value) * 4.0;
    }

    // Binary search to $1 precision.
    while (hi - lo > 1.0) {
        const mid = @floor((lo + hi) / 2.0);
        probe.annual_spending = mid;
        const rate = successRateParams(shiller.annual_returns, probe);
        if (rate >= confidence) lo = mid else hi = mid;
    }

    return .{
        .confidence = confidence,
        .annual_amount = lo,
        .withdrawal_rate = if (base.initial_value > 0) lo / base.initial_value else 0.0,
    };
}

// ── Earliest-retirement search (target-spending input) ─────────

/// Result of a `findEarliestRetirement` search. `accumulation_years
/// == null` means no value of N in [0, max_years] sustains the
/// target spending at the requested confidence over the distribution
/// horizon. The portfolio statistics are computed from the same
/// historical cycles at year `accumulation_years`.
pub const EarliestRetirement = struct {
    horizon: u16,
    confidence: f64,
    accumulation_years: ?u16,
    median_at_retirement: f64,
    p10_at_retirement: f64,
    p90_at_retirement: f64,
};

/// Maximum accumulation years to search. 50 covers a 25-year-old
/// planning to age 75. Hardcoded; if anyone hits this, route through
/// projections.srf as a config field.
pub const max_accumulation_years: u16 = 50;

/// Earliest-retirement search: given a target annual spending
/// level, find the smallest `accumulation_years` N in [0, `max_years`]
/// such that the success
/// rate over the distribution phase ≥ `confidence`.
///
/// Returns the matching `EarliestRetirement`, with portfolio
/// statistics taken from the cycles that survived. If no N up to
/// `max_years` succeeds, `accumulation_years == null` and the
/// portfolio statistics are zero.
pub fn findEarliestRetirement(
    allocator: std.mem.Allocator,
    initial_value: f64,
    stock_pct: f64,
    annual_contribution: f64,
    contribution_inflation_adjusted: bool,
    target_spending: f64,
    target_spending_inflation_adjusted: bool,
    distribution_years: u16,
    confidence: f64,
    events: []const ResolvedEvent,
    max_years: u16,
    expense_ratio: f64,
) !EarliestRetirement {
    const data = shiller.annual_returns;

    var n: u16 = 0;
    while (n <= max_years) : (n += 1) {
        const params: SimParams = .{
            .initial_value = initial_value,
            .stock_pct = stock_pct,
            .annual_spending = target_spending,
            .spending_inflation_adjusted = target_spending_inflation_adjusted,
            .distribution_years = distribution_years,
            .accumulation_years = n,
            .annual_contribution = annual_contribution,
            .contribution_inflation_adjusted = contribution_inflation_adjusted,
            .expense_ratio = expense_ratio,
            .events = events,
        };

        const rate = successRateParams(data, params);
        if (rate < confidence) continue;

        // Found the earliest N. Run the full path simulation once to
        // extract the portfolio statistics at year N (the retirement
        // boundary).
        const total = params.totalYears();
        const num_cycles = maxCyclesFor(data, total);
        if (num_cycles == 0) {
            return .{
                .horizon = distribution_years,
                .confidence = confidence,
                .accumulation_years = n,
                .median_at_retirement = 0,
                .p10_at_retirement = 0,
                .p90_at_retirement = 0,
            };
        }

        const years_len: usize = @as(usize, total) + 1;
        const path_data = try allocator.alloc(f64, num_cycles * years_len);
        defer allocator.free(path_data);
        const paths = try allocator.alloc([]f64, num_cycles);
        defer allocator.free(paths);
        for (0..num_cycles) |i| {
            paths[i] = path_data[i * years_len .. (i + 1) * years_len];
        }
        _ = runAllCyclesParams(paths, data, params);

        const sort_buf = try allocator.alloc(f64, num_cycles);
        defer allocator.free(sort_buf);
        for (0..num_cycles) |c| {
            sort_buf[c] = paths[c][@as(usize, n)];
        }
        std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64));

        return .{
            .horizon = distribution_years,
            .confidence = confidence,
            .accumulation_years = n,
            .median_at_retirement = percentile(sort_buf, 0.50),
            .p10_at_retirement = percentile(sort_buf, 0.10),
            .p90_at_retirement = percentile(sort_buf, 0.90),
        };
    }

    return .{
        .horizon = distribution_years,
        .confidence = confidence,
        .accumulation_years = null,
        .median_at_retirement = 0,
        .p10_at_retirement = 0,
        .p90_at_retirement = 0,
    };
}

// ── Earliest-retirement promotion (the "headline" cell) ────────

/// Selected (horizon, confidence) pair for the promoted retirement
/// line. The selection is independent of feasibility — the caller
/// indexes the earliest-retirement grid with this pair and renders
/// "not feasible" if the cell's `accumulation_years` is null.
pub const PromotedCell = struct {
    horizon_index: usize,
    confidence_index: usize,
    /// True when the user explicitly tagged a horizon with a
    /// `retirement_target` annotation. Diagnostic only — display
    /// behavior is identical either way.
    explicit: bool,
};

/// Maximum age the "longest-horizon-that-makes-sense" rule allows
/// the oldest configured person to reach by the end of the promoted
/// distribution. A 100-year-old shouldn't still be drawing down
/// their working-age portfolio; if all horizons push past this, we
/// fall through to the shortest configured horizon anyway ("fuck it"
/// branch).
pub const promotion_age_cap: u16 = 100;

/// Pick the (horizon, confidence) cell to promote into the
/// retirement line and accumulation block when the user configured
/// `target_spending` without an explicit retirement date.
///
/// Algorithm:
///   1. If exactly one horizon is annotated with `retirement_target`,
///      honor that annotation regardless of length or feasibility.
///   2. Else, walk horizons longest → shortest. Pick the longest
///      whose end year keeps the oldest configured person under
///      `promotion_age_cap`.
///   3. If even the shortest horizon overshoots, use it anyway.
///   4. Default confidence is 99% (most conservative).
///
/// `confidence_levels` must match the order used by the earliest
/// grid — typically {.90, .95, .99} with index 2 being 99%.
///
/// `as_of` is the reference date used to compute the oldest
/// person's current age. The function works correctly for any
/// reference date — pass today for the live mode or a historical
/// snapshot date for back-dated runs.
///
/// Returns null only if no horizons are configured at all (caller
/// should treat this as "no promotion possible").
pub fn pickPromotedCell(
    config: *const UserConfig,
    as_of: Date,
    confidence_levels: []const f64,
) ?PromotedCell {
    if (config.horizon_count == 0 or confidence_levels.len == 0) return null;

    // Step 1: explicit override wins.
    var i: usize = 0;
    while (i < config.horizon_count) : (i += 1) {
        const tag = config.horizon_targets[i];
        if (tag != 0) {
            const ci = confidenceIndex(confidence_levels, tag);
            return .{ .horizon_index = i, .confidence_index = ci, .explicit = true };
        }
    }

    // Default confidence: highest configured (most conservative).
    // Convention: arrays sorted ascending, so the last entry is the
    // highest. Find the index whose value is closest to 0.99.
    const default_ci = confidenceIndex(confidence_levels, 99);

    // Step 2: longest horizon where oldest person stays under the
    // age cap. With no birthdates, the cap doesn't apply — just
    // pick the longest horizon.
    const oldest_age_as_of = config.oldestAge(as_of);

    var longest_idx: usize = 0;
    var longest_h: u16 = config.horizons[0];
    for (1..config.horizon_count) |hi| {
        if (config.horizons[hi] > longest_h) {
            longest_h = config.horizons[hi];
            longest_idx = hi;
        }
    }
    if (config.birthdate_count == 0) {
        return .{ .horizon_index = longest_idx, .confidence_index = default_ci, .explicit = false };
    }

    // Sort indices by horizon length descending.
    var order: [UserConfig.max_horizons]u8 = @splat(0);
    for (0..config.horizon_count) |hi| order[hi] = @intCast(hi);
    const slice = order[0..config.horizon_count];
    const SortCtx = struct {
        horizons: []const u16,
        pub fn lessThan(ctx: @This(), a: u8, b: u8) bool {
            return ctx.horizons[a] > ctx.horizons[b]; // descending
        }
    };
    std.mem.sort(u8, slice, SortCtx{ .horizons = &config.horizons }, SortCtx.lessThan);

    for (slice) |hi| {
        const end_age = oldest_age_as_of + config.horizons[hi];
        if (end_age < promotion_age_cap) {
            return .{ .horizon_index = hi, .confidence_index = default_ci, .explicit = false };
        }
    }

    // Step 3: "fuck it" — even the shortest horizon overshoots.
    // Pick the shortest (last in our descending sort).
    const shortest_idx = slice[slice.len - 1];
    return .{ .horizon_index = shortest_idx, .confidence_index = default_ci, .explicit = false };
}

/// Find the index in `confidence_levels` (a slice of fractions like
/// 0.90/0.95/0.99) that corresponds to the percentage `pct`. Falls
/// back to the closest match if no exact one exists. Used to
/// translate a `retirement_target` annotation (90/95/99) or the
/// default 99 into an index into the earliest-retirement grid.
fn confidenceIndex(confidence_levels: []const f64, pct: u8) usize {
    const target: f64 = @as(f64, @floatFromInt(pct)) / 100.0;
    var best_idx: usize = 0;
    var best_diff: f64 = std.math.inf(f64);
    for (confidence_levels, 0..) |c, idx| {
        const diff = @abs(c - target);
        if (diff < best_diff) {
            best_diff = diff;
            best_idx = idx;
        }
    }
    return best_idx;
}

// ── Percentile bands ───────────────────────────────────────────

/// Two-phase variant of `computePercentileBands`. Returns bands of
/// length `params.totalYears() + 1`, where index 0 is the starting
/// portfolio and index `accumulation_years` is the post-accumulation
/// (retirement) portfolio.
pub fn computePercentileBandsParams(
    allocator: std.mem.Allocator,
    params: SimParams,
) ![]YearPercentiles {
    const data = shiller.annual_returns;
    const total = params.totalYears();
    const num_cycles = maxCyclesFor(data, total);
    if (num_cycles == 0) return &.{};

    const years: usize = @as(usize, total) + 1;

    const path_data = try allocator.alloc(f64, num_cycles * years);
    defer allocator.free(path_data);

    const paths = try allocator.alloc([]f64, num_cycles);
    defer allocator.free(paths);

    for (0..num_cycles) |i| {
        paths[i] = path_data[i * years .. (i + 1) * years];
    }

    _ = runAllCyclesParams(paths, data, params);

    // For each year, sort the values across all cycles and extract percentiles
    const bands = try allocator.alloc(YearPercentiles, years);

    // Temporary buffer for sorting one year's values
    const sort_buf = try allocator.alloc(f64, num_cycles);
    defer allocator.free(sort_buf);

    for (0..years) |y| {
        // Collect values for year y across all cycles
        for (0..num_cycles) |c| {
            sort_buf[c] = paths[c][y];
        }
        std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64));

        bands[y] = .{
            .year = @intCast(y),
            .p10 = percentile(sort_buf, 0.10),
            .p25 = percentile(sort_buf, 0.25),
            .p50 = percentile(sort_buf, 0.50),
            .p75 = percentile(sort_buf, 0.75),
            .p90 = percentile(sort_buf, 0.90),
        };
    }

    return bands;
}

/// Linear interpolation percentile on a sorted slice.
fn percentile(sorted: []const f64, p: f64) f64 {
    if (sorted.len == 0) return 0;
    if (sorted.len == 1) return sorted[0];

    const n: f64 = @floatFromInt(sorted.len - 1);
    const idx = p * n;
    const lo_idx: usize = @intFromFloat(@floor(idx));
    const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1);
    const frac = idx - @floor(idx);

    return sorted[lo_idx] * (1.0 - frac) + sorted[hi_idx] * frac;
}

// ── High-level API ─────────────────────────────────────────────

/// Pre-computed grid of safe-withdrawal results and percentile
/// bands across a set of horizons × confidence levels. Produced by
/// `runProjectionGrid` and consumed by both the CLI and TUI
/// projections renderers.
pub const ProjectionData = struct {
    /// Safe withdrawal results, indexed `[ci * horizons.len + hi]`.
    /// Owned by the caller — free with the same allocator.
    withdrawals: []WithdrawalResult,
    /// Per-horizon percentile bands. `null` entries indicate the
    /// band computation failed for that horizon (allocator failure,
    /// out-of-data, etc.). Each non-null slice is owned by the
    /// caller.
    bands: []?[]YearPercentiles,
    /// Index into `confidence_levels` corresponding to the 99%
    /// (highest configured) level. The chart and percentile-band
    /// blocks anchor on this confidence.
    ci_99: usize,
};

/// Run the full projection-display batch up front: a safe-withdrawal
/// grid across every (horizon × confidence) pair, plus a percentile
/// band per horizon at the highest configured confidence (used for
/// the chart and the terminal-portfolio-value table).
///
/// Two distinct computations bundled in one call:
///
///   1. **Safe-withdrawal grid.** For each `(horizon, confidence)`
///      pair in `confidence_levels × horizons`, binary-searches the
///      maximum annual spending the portfolio can sustain across
///      that horizon at that confidence. Stored in
///      `withdrawals[ci * horizons.len + hi]`.
///
///   2. **Percentile bands.** For each horizon, runs the full
///      historical simulation at the highest-confidence withdrawal
///      rate and extracts p10/p25/p50/p75/p90 of the portfolio
///      value at each year. The highest-confidence rate is the
///      most conservative spending level, so the chart shows the
///      steepest survivable drawdown. Stored in `bands[hi]`.
///
/// All four call sites in the codebase (CLI projections command,
/// TUI projections tab, the `--vs` comparison renderer, and the
/// view-model integration test) want exactly this bundle, so it's
/// computed once per projection rather than re-derived per render.
///
/// Accumulation parameters are always honored — pass `0` /
/// `0` / `true` for the distribution-only case (already-retired
/// users, no contributions configured). The simulation core
/// produces identical results when accumulation degenerates to
/// zero, so this single function covers every input combination
/// `projections.srf` allows.
///
/// `confidence_levels` should be sorted ascending; `ci_99` in the
/// returned struct refers to the LAST entry, which by convention
/// is the highest (most-conservative) confidence.
///
/// Caller owns `withdrawals`, `bands`, and every non-null entry
/// inside `bands`. Free with the same allocator.
pub fn runProjectionGrid(
    alloc: std.mem.Allocator,
    horizons: []const u16,
    confidence_levels: []const f64,
    total_value: f64,
    stock_pct: f64,
    events: []const ResolvedEvent,
    accumulation_years: u16,
    annual_contribution: f64,
    contribution_inflation_adjusted: bool,
    expense_ratio: f64,
) !ProjectionData {
    const num_results = horizons.len * confidence_levels.len;
    const withdrawals = try alloc.alloc(WithdrawalResult, num_results);
    for (confidence_levels, 0..) |conf, ci| {
        for (horizons, 0..) |h, hi| {
            withdrawals[ci * horizons.len + hi] = findSafeWithdrawalWithAccumulation(
                h,
                total_value,
                stock_pct,
                conf,
                events,
                accumulation_years,
                annual_contribution,
                contribution_inflation_adjusted,
                expense_ratio,
            );
        }
    }
    const ci_99 = confidence_levels.len - 1;
    const bands = try alloc.alloc(?[]YearPercentiles, horizons.len);
    for (horizons, 0..) |h, hi| {
        const wr = withdrawals[ci_99 * horizons.len + hi];
        bands[hi] = computePercentileBandsParams(alloc, .{
            .initial_value = total_value,
            .stock_pct = stock_pct,
            .annual_spending = wr.annual_amount,
            .distribution_years = h,
            .accumulation_years = accumulation_years,
            .annual_contribution = annual_contribution,
            .contribution_inflation_adjusted = contribution_inflation_adjusted,
            .expense_ratio = expense_ratio,
            .events = events,
        }) catch null;
    }
    return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
}

// ── Test-only convenience wrappers ─────────────────────────────
//
// Thin, distribution-only, zero-fee wrappers over the production
// `*Params` entry points (`searchSafeWithdrawal`, `successRateParams`,
// `computePercentileBandsParams`). Nothing in the CLI/TUI calls these
// -- production goes through `runProjectionGrid` /
// `findSafeWithdrawalWithAccumulation`. They exist only to give the
// test suite (including the FIRECalc parity tests) an ergonomic
// primitive, so they live next to the tests and are `fn`-private.

/// Maximum annual withdrawal (today's dollars) that survives `horizon`
/// years in at least `confidence` of historical cycles. Binary search
/// to $1 precision via `searchSafeWithdrawal`.
fn findSafeWithdrawal(
    horizon: u16,
    initial_value: f64,
    stock_pct: f64,
    confidence: f64,
    events: []const ResolvedEvent,
) WithdrawalResult {
    return searchSafeWithdrawal(.{
        .initial_value = initial_value,
        .stock_pct = stock_pct,
        .annual_spending = 0, // overwritten by the search loop
        .distribution_years = horizon,
        .events = events,
    }, confidence);
}

/// Success rate (fraction of cycles that survived) for a given
/// spending level.
fn successRate(
    horizon: u16,
    initial_value: f64,
    annual_spending: f64,
    stock_pct: f64,
    events: []const ResolvedEvent,
) f64 {
    return successRateParams(shiller.annual_returns, .{
        .initial_value = initial_value,
        .stock_pct = stock_pct,
        .annual_spending = annual_spending,
        .distribution_years = horizon,
        .events = events,
    });
}

/// Percentile bands across all simulated paths for a horizon and
/// spending level. Allocates the result.
fn computePercentileBands(
    allocator: std.mem.Allocator,
    horizon: u16,
    initial_value: f64,
    annual_spending: f64,
    stock_pct: f64,
    events: []const ResolvedEvent,
) ![]YearPercentiles {
    return computePercentileBandsParams(allocator, .{
        .initial_value = initial_value,
        .stock_pct = stock_pct,
        .annual_spending = annual_spending,
        .distribution_years = horizon,
        .events = events,
    });
}

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

test "successRate with zero spending is 100%" {
    const rate = successRate(30, 1_000_000, 0, 0.75, &.{});
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001);
}

test "successRate with excessive spending is 0%" {
    // Spending the entire portfolio in year 1 should fail every cycle
    const rate = successRate(30, 1_000_000, 1_000_000, 0.75, &.{});
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001);
}

test "successRate decreases with higher spending" {
    const rate_low = successRate(30, 1_000_000, 20_000, 0.75, &.{});
    const rate_mid = successRate(30, 1_000_000, 40_000, 0.75, &.{});
    const rate_high = successRate(30, 1_000_000, 60_000, 0.75, &.{});

    try std.testing.expect(rate_low >= rate_mid);
    try std.testing.expect(rate_mid >= rate_high);
}

test "findSafeWithdrawal produces reasonable results" {
    const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});

    try std.testing.expect(result.annual_amount >= 10_000);
    try std.testing.expect(result.annual_amount <= 60_000);
    try std.testing.expect(result.withdrawal_rate >= 0.01);
    try std.testing.expect(result.withdrawal_rate <= 0.06);
}

test "higher confidence means lower withdrawal" {
    const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90, &.{});
    const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99, &.{});

    try std.testing.expect(r90.annual_amount >= r95.annual_amount);
    try std.testing.expect(r95.annual_amount >= r99.annual_amount);
}

test "longer horizon means lower withdrawal" {
    const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95, &.{});
    const r30 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    const r45 = findSafeWithdrawal(45, 1_000_000, 0.75, 0.95, &.{});

    try std.testing.expect(r20.annual_amount >= r30.annual_amount);
    try std.testing.expect(r30.annual_amount >= r45.annual_amount);
}

test "computePercentileBands basic properties" {
    const allocator = std.testing.allocator;
    const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75, &.{});
    defer allocator.free(bands);

    // Should have horizon + 1 entries
    try std.testing.expectEqual(@as(usize, 31), bands.len);

    // Year 0 should be the starting value for all percentiles
    try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands[0].p50, 1.0);

    // Percentiles should be ordered at each year
    for (bands) |b| {
        try std.testing.expect(b.p10 <= b.p25);
        try std.testing.expect(b.p25 <= b.p50);
        try std.testing.expect(b.p50 <= b.p75);
        try std.testing.expect(b.p75 <= b.p90);
    }
}

test "percentile interpolation" {
    const data = [_]f64{ 10, 20, 30, 40, 50 };
    try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 30.0), percentile(&data, 0.5), 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 50.0), percentile(&data, 1.0), 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 20.0), percentile(&data, 0.25), 0.01);
}

test "realistic portfolio safe withdrawal" {
    // Approximate real portfolio: ~$8.34M, ~82.5% stocks
    const portfolio = 8_340_000;
    const stock_pct = 0.825;

    const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99, &.{});
    const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95, &.{});
    const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99, &.{});

    // 95% should be higher than 99%
    try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount);
    // 30yr should be higher than 45yr at same confidence
    try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount);
    // FIRECalc reference: on $7.7M at 82% / 45yr / 99% (fee=0), FIRECalc
    // returns ~$262.8K (audit June 2026). zfin runs ~+9% optimistic (see
    // the FIRECalc parity suite below for the why), so on $8.34M it lands
    // ~$310K. Bounds bracket that with margin.
    try std.testing.expect(r99_45.annual_amount >= 290_000);
    try std.testing.expect(r99_45.annual_amount <= 350_000);
    try std.testing.expect(r99_45.withdrawal_rate >= 0.03);
    try std.testing.expect(r99_45.withdrawal_rate <= 0.05);
}

// ── FIRECalc.com parity suite ──────────────────────────────────
//
// Cross-checks zfin's engine against FIRECalc.com ("FIRECalc 3.0",
// data through 1/1/2026 -- the same 1871-2025 Shiller span zfin embeds).
// Reference values were captured June 2026 by driving the FIRECalc web
// form directly. Full method, captured numbers, and root-cause analysis
// live in docs/explanation/projections-model.md → "Parity with FIRECalc".
//
// The safe-withdrawal and success-rate references below use FIRECalc
// with its expense ratio set to 0% (InvExp=0) and the default "Long
// Interest" (10yr-Treasury) fixed-income model. fee=0 is the
// apples-to-apples comparison for the no-fee convenience wrappers
// (`findSafeWithdrawal`, `successRate`). zfin CAN now model a fee
// (`SimParams.expense_ratio`, configurable via projections.srf); the
// separate "expense ratio matches FIRECalc's default fee" test below
// pins zfin against FIRECalc's *default* 0.18%-fee runs.
//
// Cohort counts now match FIRECalc exactly (e.g. 126 for a 30yr
// horizon over 1871-2025) after the `maxCyclesFor` off-by-one fix.
//
// KNOWN, ACCEPTED DIVERGENCE: zfin runs systematically *more optimistic*
// than FIRECalc -- ~+6-9% on safe-withdrawal dollars and ~+2-3pp on
// success rate -- because zfin reconstructs nominal equity total returns
// from Shiller's monthly-reinvested Real Total Return Price × CPI, which
// compounds ~0.2-0.3%/yr higher than FIRECalc's equity series. This was
// isolated with a $0-spending, 100%-stock run (no withdrawal/timing/fee
// effects): for the 1966 cohort, zfin's year-30 nominal balance is
// $20.24M vs FIRECalc's $18.60M -- a pure return-series gap. It is a
// defensible modeling choice, not a bug; the tolerances below encode the
// gap so this suite is a regression guard, not an exact-match assertion.

const FcSwrCase = struct {
    name: []const u8,
    horizon: u16,
    value: f64,
    stock_pct: f64,
    confidence: f64,
    /// FIRECalc max-spending dollars for this scenario (InvExp=0).
    fc_ref: f64,
};

test "FIRECalc parity: safe-withdrawal dollars" {
    const cases = [_]FcSwrCase{
        .{ .name = "100% 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 39_697 },
        .{ .name = "75/25 30y 95% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 0.75, .confidence = 0.95, .fc_ref = 41_221 },
        .{ .name = "100% 45y 95% $1M", .horizon = 45, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 35_835 },
        .{ .name = "100% 20y 95% $1M", .horizon = 20, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.95, .fc_ref = 45_879 },
        .{ .name = "100% 30y 90% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.90, .fc_ref = 43_804 },
        .{ .name = "100% 30y 99% $1M", .horizon = 30, .value = 1_000_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 35_864 },
        .{ .name = "100% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 1.00, .confidence = 0.99, .fc_ref = 254_461 },
        .{ .name = "82% 45y 99% $7.7M", .horizon = 45, .value = 7_700_000, .stock_pct = 0.82, .confidence = 0.99, .fc_ref = 262_770 },
    };
    for (cases) |c| {
        const r = findSafeWithdrawal(c.horizon, c.value, c.stock_pct, c.confidence, &.{});
        // zfin tracks FIRECalc within roughly -3% / +15%, currently
        // landing ~+6-9% high (equity return-series optimism). The lower
        // bound catches an engine that suddenly turns conservative; the
        // upper bound catches runaway optimism.
        try std.testing.expect(r.annual_amount >= c.fc_ref * 0.97);
        try std.testing.expect(r.annual_amount <= c.fc_ref * 1.15);
    }
}

test "FIRECalc parity: success rate" {
    // $1M, $40k/yr, 30yr, InvExp=0. FIRECalc: 100% stock → 94.4%
    // (7/126 failed); 75/25 → 96.8% (4/126). zfin runs ~+2-3pp higher
    // (fewer failures) for the same return-series reason.
    const sr_100 = successRate(30, 1_000_000, 40_000, 1.00, &.{});
    const sr_75 = successRate(30, 1_000_000, 40_000, 0.75, &.{});
    // Within 6pp of FIRECalc, and never *below* it by more than 1pp
    // (zfin is the more optimistic engine -- a large undershoot would be
    // a regression).
    try std.testing.expectApproxEqAbs(@as(f64, 0.944), sr_100, 0.06);
    try std.testing.expectApproxEqAbs(@as(f64, 0.968), sr_75, 0.06);
    try std.testing.expect(sr_100 >= 0.944 - 0.01);
    try std.testing.expect(sr_75 >= 0.968 - 0.01);
}

test "FIRECalc parity: terminal-value percentiles" {
    // S1: $1M, $40k, 30yr, 100% stock, InvExp=0. FIRECalc terminal
    // values, captured from its per-cohort spreadsheet export in NOMINAL
    // dollars (126 cohorts): p50 ~$5.12M, p90 ~$12.76M.
    //
    // Unit caveat: FIRECalc's on-screen "ending portfolio" figures are
    // REAL (start-of-retirement dollars); zfin's bands are NOMINAL. The
    // spreadsheet export is nominal, which is the basis used here. zfin's
    // percentiles run higher (median ~+11%, p90 ~+16%) for the same
    // return-series reason; the p10 gap is larger still because small
    // per-year differences explode near the failure boundary, so p10 is
    // intentionally not asserted.
    const a = std.testing.allocator;
    const bands = try computePercentileBands(a, 30, 1_000_000, 40_000, 1.00, &.{});
    defer a.free(bands);
    const term = bands[30];
    try std.testing.expect(term.p50 >= 5_124_810 * 0.97);
    try std.testing.expect(term.p50 <= 5_124_810 * 1.20);
    try std.testing.expect(term.p90 >= 12_763_289 * 0.97);
    try std.testing.expect(term.p90 <= 12_763_289 * 1.25);
}

test "FIRECalc parity: expense ratio matches FIRECalc's default fee" {
    // Validates the `expense_ratio` model against FIRECalc runs with
    // its *default* 0.18% fee enabled (InvExp=0.18). zfin's
    // expense_ratio is a decimal here (0.0018 = 0.18%).
    //
    // Two things this pins:
    //   1. The fee has the right *direction and magnitude*: enabling
    //      0.18% drops zfin's SWR ~1.8%, matching FIRECalc's own
    //      ~2.0% fee effect (W2->W3: $41,221->$40,381).
    //   2. With fees matched on BOTH sides, the residual gap is still
    //      ~+7-9% — i.e. the fee is NOT the source of the divergence;
    //      the equity return series (documented above) is. So the
    //      same -3%/+15% tolerance band applies.
    const sr_100 = successRateParams(shiller.annual_returns, .{
        .initial_value = 1_000_000,
        .stock_pct = 1.00,
        .annual_spending = 40_000,
        .distribution_years = 30,
        .expense_ratio = 0.0018,
    });
    const sr_75 = successRateParams(shiller.annual_returns, .{
        .initial_value = 1_000_000,
        .stock_pct = 0.75,
        .annual_spending = 40_000,
        .distribution_years = 30,
        .expense_ratio = 0.0018,
    });
    // FIRECalc fee=0.18: 100% stock 93.7%, 75/25 95.2%.
    try std.testing.expectApproxEqAbs(@as(f64, 0.937), sr_100, 0.06);
    try std.testing.expectApproxEqAbs(@as(f64, 0.952), sr_75, 0.06);

    // Safe withdrawal with the fee on. FIRECalc fee=0.18 refs:
    // 75/25 30y 95% -> $40,381; 75/25 45y 99% $7.7M -> $258,747.
    const w_30 = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0.0018);
    const w_45 = findSafeWithdrawalWithAccumulation(45, 7_700_000, 0.75, 0.99, &.{}, 0, 0, true, 0.0018);
    try std.testing.expect(w_30.annual_amount >= 40_381 * 0.97);
    try std.testing.expect(w_30.annual_amount <= 40_381 * 1.15);
    try std.testing.expect(w_45.annual_amount >= 258_747 * 0.97);
    try std.testing.expect(w_45.annual_amount <= 258_747 * 1.15);

    // Sanity: enabling the fee strictly lowers the safe withdrawal
    // relative to the no-fee result (same scenario).
    const w_30_nofee = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    try std.testing.expect(w_30.annual_amount < w_30_nofee.annual_amount);
}

test "simulateCycle produces correct year-0 value" {
    var buf: [31]f64 = undefined;
    simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
    try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01);
}

test "simulateCycle with zero spending grows portfolio" {
    var buf: [31]f64 = undefined;
    simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75, &.{});
    // Over any 30-year period in history, zero spending should grow the portfolio
    try std.testing.expect(buf[30] > 1_000_000);
}

test "parseProjectionsConfig defaults" {
    const config = parseProjectionsConfig(null);
    try std.testing.expect(config.target_stock_pct == null);
    try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]);
    try std.testing.expectEqual(@as(u16, 30), config.getHorizons()[1]);
    try std.testing.expectEqual(@as(u16, 45), config.getHorizons()[2]);
}

test "parseProjectionsConfig from SRF" {
    const data =
        \\#!srfv1
        \\type::config,target_stock_pct:num:77
        \\type::config,horizon:num:25
        \\type::config,horizon:num:35
        \\type::config,horizon:num:50
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01);
    try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 25), config.getHorizons()[0]);
    try std.testing.expectEqual(@as(u16, 35), config.getHorizons()[1]);
    try std.testing.expectEqual(@as(u16, 50), config.getHorizons()[2]);
}

test "parseProjectionsConfig partial" {
    const data = "#!srfv1\ntype::config,target_stock_pct:num:82.5\n";
    const config = parseProjectionsConfig(data);
    try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01);
    // Horizons should remain default
    try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]);
}

test "parseProjectionsConfig empty string" {
    const config = parseProjectionsConfig("");
    try std.testing.expect(config.target_stock_pct == null);
    try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
}

test "parseProjectionsConfig expense_ratio defaults to 0.18 and parses overrides" {
    const default_config = parseProjectionsConfig("#!srfv1\n");
    // Default is FIRECalc's 0.18% (a realistic fund-fee assumption),
    // not 0 -- modeling no fee is less accurate and over-optimistic.
    try std.testing.expectApproxEqAbs(@as(f64, 0.18), default_config.expense_ratio, 0.0001);

    // A low-cost index investor overrides downward; verify parsing.
    const data = "#!srfv1\ntype::config,expense_ratio:num:0.04\n";
    const config = parseProjectionsConfig(data);
    // Stored as a percentage (like target_stock_pct); the view layer
    // divides by 100 before handing it to the simulation.
    try std.testing.expectApproxEqAbs(@as(f64, 0.04), config.expense_ratio, 0.0001);

    // Explicit 0 is honored (all-individual-stock portfolio).
    const zeroed = parseProjectionsConfig("#!srfv1\ntype::config,expense_ratio:num:0\n");
    try std.testing.expectEqual(@as(f64, 0), zeroed.expense_ratio);
}

test "parseProjectionsConfig invalid data" {
    const config = parseProjectionsConfig("not valid srf");
    try std.testing.expect(config.target_stock_pct == null);
}

test "parseProjectionsConfig horizon_age parsed raw" {
    const data =
        \\#!srfv1
        \\type::config,horizon_age:num:90
        \\type::config,horizon_age:num:95
        \\type::birthdate,date::1975-03-15
    ;
    const config = parseProjectionsConfig(data);
    // horizon_ages are stored raw; not yet resolved into horizons.
    try std.testing.expectEqual(@as(u8, 2), config.horizon_age_count);
    try std.testing.expectEqual(@as(u16, 90), config.horizon_ages[0]);
    try std.testing.expectEqual(@as(u16, 95), config.horizon_ages[1]);
    // A horizon_age record counts as "saw_horizon", so the default
    // {20,30,45} is cleared. horizon_count is 0 until resolution.
    try std.testing.expectEqual(@as(u8, 0), config.horizon_count);
}

test "resolveHorizonAges uses oldest birthdate (first-to-hit semantics)" {
    // Person 1: born 1975, age 50 as of 2025. Person 2: born 1980, age 45.
    // Target age 90 → 90 − 50 = 40 years (first to hit 90 is the older).
    var config = parseProjectionsConfig(
        \\#!srfv1
        \\type::config,horizon_age:num:90
        \\type::birthdate,date::1975-06-15
        \\type::birthdate,date::1980-06-15,person:num:2
    );
    const as_of = Date.fromYmd(2025, 6, 15);
    try config.resolveHorizonAges(as_of);
    try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
    // Resolved; horizon_age_count cleared to make resolve idempotent.
    try std.testing.expectEqual(@as(u8, 0), config.horizon_age_count);
}

test "resolveHorizonAges errors without a birthdate" {
    var config = parseProjectionsConfig(
        \\#!srfv1
        \\type::config,horizon_age:num:90
    );
    const as_of = Date.fromYmd(2025, 1, 1);
    try std.testing.expectError(error.HorizonAgeWithoutBirthdate, config.resolveHorizonAges(as_of));
}

test "resolveHorizonAges skips targets already in the past" {
    // Oldest age is 60 as of 2025; target 40 is already past — skipped.
    var config = parseProjectionsConfig(
        \\#!srfv1
        \\type::config,horizon_age:num:40
        \\type::config,horizon_age:num:90
        \\type::birthdate,date::1965-01-01
    );
    const as_of = Date.fromYmd(2025, 6, 15);
    try config.resolveHorizonAges(as_of);
    // Only age 90 resolves (90 − 60 = 30).
    try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
}

test "resolveHorizonAges mixes with explicit horizon records" {
    var config = parseProjectionsConfig(
        \\#!srfv1
        \\type::config,horizon:num:30
        \\type::config,horizon_age:num:95
        \\type::birthdate,date::1975-06-15
    );
    const as_of = Date.fromYmd(2025, 6, 15);
    try config.resolveHorizonAges(as_of);
    // Explicit 30 from `horizon`, then appended 95 − 50 = 45 from `horizon_age`.
    try std.testing.expectEqual(@as(u8, 2), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
    try std.testing.expectEqual(@as(u16, 45), config.horizons[1]);
}

test "resolveHorizonAges is a no-op when nothing to resolve" {
    var config = parseProjectionsConfig(
        \\#!srfv1
        \\type::config,horizon:num:30
    );
    // No birthdate, no horizon_age → should succeed, not error.
    const as_of = Date.fromYmd(2025, 1, 1);
    try config.resolveHorizonAges(as_of);
    try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 30), config.horizons[0]);
}

test "UserConfig getHorizons default" {
    const config = UserConfig{};
    const horizons = config.getHorizons();
    try std.testing.expectEqual(@as(usize, 3), horizons.len);
    try std.testing.expectEqual(@as(u16, 20), horizons[0]);
    try std.testing.expectEqual(@as(u16, 30), horizons[1]);
    try std.testing.expectEqual(@as(u16, 45), horizons[2]);
}

test "UserConfig getConfidenceLevels" {
    const config = UserConfig{};
    const levels = config.getConfidenceLevels();
    try std.testing.expectEqual(@as(usize, 3), levels.len);
    try std.testing.expectApproxEqAbs(@as(f64, 0.90), levels[0], 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.95), levels[1], 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001);
}

test "LifeEvent.startYear basic" {
    const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
    const ages = [_]u16{50};
    try std.testing.expectEqual(@as(?u16, 17), ev.startYear(&ages));
}

test "LifeEvent.startYear already active" {
    const ev = LifeEvent{ .start_age = 40, .person = 0, .annual_amount = 38400 };
    const ages = [_]u16{50};
    try std.testing.expectEqual(@as(?u16, 0), ev.startYear(&ages));
}

test "LifeEvent.startYear person out of range" {
    const ev = LifeEvent{ .start_age = 67, .person = 5, .annual_amount = 38400 };
    const ages = [_]u16{50};
    try std.testing.expectEqual(@as(?u16, null), ev.startYear(&ages));
}

test "LifeEvent.isActive permanent" {
    const ev = LifeEvent{ .start_age = 60, .person = 0, .duration = 0, .annual_amount = 38400 };
    const ages = [_]u16{50};
    try std.testing.expect(!ev.isActive(9, &ages)); // before start (year 10)
    try std.testing.expect(ev.isActive(10, &ages)); // start year
    try std.testing.expect(ev.isActive(30, &ages)); // well after
}

test "LifeEvent.isActive with duration" {
    const ev = LifeEvent{ .start_age = 53, .person = 0, .duration = 4, .annual_amount = -60000 };
    const ages = [_]u16{50};
    try std.testing.expect(!ev.isActive(2, &ages)); // before start
    try std.testing.expect(ev.isActive(3, &ages)); // year 3 (age 53)
    try std.testing.expect(ev.isActive(6, &ages)); // year 6 (age 56, last year)
    try std.testing.expect(!ev.isActive(7, &ages)); // year 7 (age 57, past duration)
}

test "LifeEvent.cashFlow inflation adjusted" {
    const ev = LifeEvent{ .start_age = 50, .person = 0, .annual_amount = 10000, .inflation_adjusted = true };
    const ages = [_]u16{50};
    try std.testing.expectApproxEqAbs(@as(f64, 12000), ev.cashFlow(0, 1.2, &ages), 0.01);
}

test "LifeEvent.cashFlow nominal" {
    const ev = LifeEvent{ .start_age = 50, .person = 0, .annual_amount = 10000, .inflation_adjusted = false };
    const ages = [_]u16{50};
    try std.testing.expectApproxEqAbs(@as(f64, 10000), ev.cashFlow(0, 1.2, &ages), 0.01);
}

test "LifeEvent.cashFlow inactive returns zero" {
    const ev = LifeEvent{ .start_age = 67, .person = 0, .annual_amount = 38400 };
    const ages = [_]u16{50};
    try std.testing.expectApproxEqAbs(@as(f64, 0), ev.cashFlow(5, 1.0, &ages), 0.01);
}

test "parseProjectionsConfig birthdates and events" {
    const data =
        \\#!srfv1
        \\type::config,target_stock_pct:num:80
        \\type::config,horizon:num:30
        \\type::birthdate,date::1975-03-15
        \\type::birthdate,date::1978-06-22,person:num:2
        \\type::event,name::Social Security,start_age:num:67,person:num:1,amount:num:38400
        \\type::event,name::College,start_age:num:53,duration:num:4,amount:num:-60000,inflation_adjusted:bool:false
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectApproxEqAbs(@as(f64, 80.0), config.target_stock_pct.?, 0.01);
    try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
    try std.testing.expectEqual(@as(u8, 2), config.birthdate_count);
    try std.testing.expectEqual(@as(i16, 1975), config.birthdates[0].year());
    try std.testing.expectEqual(@as(i16, 1978), config.birthdates[1].year());
    try std.testing.expectEqual(@as(u8, 2), config.event_count);
    // First event: Social Security
    const ev0 = config.events[0];
    try std.testing.expectEqualStrings("Social Security", ev0.getName());
    try std.testing.expectEqual(@as(u16, 67), ev0.start_age);
    try std.testing.expectEqual(@as(u8, 0), ev0.person);
    try std.testing.expectEqual(@as(u16, 0), ev0.duration);
    try std.testing.expectApproxEqAbs(@as(f64, 38400), ev0.annual_amount, 0.01);
    try std.testing.expect(ev0.inflation_adjusted);
    // Second event: College
    const ev1 = config.events[1];
    try std.testing.expectEqualStrings("College", ev1.getName());
    try std.testing.expectEqual(@as(u16, 53), ev1.start_age);
    try std.testing.expectEqual(@as(u16, 4), ev1.duration);
    try std.testing.expectApproxEqAbs(@as(f64, -60000), ev1.annual_amount, 0.01);
    try std.testing.expect(!ev1.inflation_adjusted);
}

test "income event increases safe withdrawal" {
    // With a permanent $20K/yr income event starting immediately,
    // the safe withdrawal should be higher than without.
    const no_events = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    const income_event = [_]ResolvedEvent{.{
        .start_year = 0,
        .duration = 0,
        .annual_amount = 20_000,
        .inflation_adjusted = true,
    }};
    const with_income = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &income_event);
    try std.testing.expect(with_income.annual_amount > no_events.annual_amount);
    // The increase should be roughly $20K (the income offsets withdrawal)
    const diff = with_income.annual_amount - no_events.annual_amount;
    try std.testing.expect(diff >= 15_000 and diff <= 25_000);
}

test "expense event decreases safe withdrawal" {
    const no_events = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    const expense_event = [_]ResolvedEvent{.{
        .start_year = 0,
        .duration = 5,
        .annual_amount = -20_000,
        .inflation_adjusted = true,
    }};
    const with_expense = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &expense_event);
    try std.testing.expect(with_expense.annual_amount < no_events.annual_amount);
}

test "UserConfig.eventNetCashFlow sums active events" {
    var config = UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1975, 1, 1);
    config.events[0] = .{ .start_age = 50, .person = 0, .annual_amount = 30000 };
    config.events[1] = .{ .start_age = 55, .person = 0, .annual_amount = 10000 };
    config.event_count = 2;
    const ages = [_]u16{50};
    // At year 0: only first event active (age 50)
    try std.testing.expectApproxEqAbs(@as(f64, 30000), config.eventNetCashFlow(0, 1.0, &ages), 0.01);
    // At year 5: both active (ages 55, 55)
    try std.testing.expectApproxEqAbs(@as(f64, 40000), config.eventNetCashFlow(5, 1.0, &ages), 0.01);
}

// ── Accumulation phase tests ───────────────────────────────────

test "parseProjectionsConfig parses retirement_age" {
    const data =
        \\#!srfv1
        \\type::config,retirement_age:num:65
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqual(@as(?u16, 65), config.retirement_age);
    try std.testing.expectEqual(@as(?Date, null), config.retirement_at);
}

test "parseProjectionsConfig parses retirement_at" {
    const data =
        \\#!srfv1
        \\type::config,retirement_at::2036-07-01
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expect(config.retirement_at != null);
    try std.testing.expectEqual(@as(i16, 2036), config.retirement_at.?.year());
    try std.testing.expectEqual(@as(u8, 7), config.retirement_at.?.month());
    try std.testing.expectEqual(@as(u8, 1), config.retirement_at.?.day());
}

test "parseProjectionsConfig parses annual_contribution" {
    const data =
        \\#!srfv1
        \\type::config,annual_contribution:num:100000
        \\type::config,contribution_inflation_adjusted:bool:false
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectApproxEqAbs(@as(f64, 100_000), config.annual_contribution, 0.01);
    try std.testing.expect(!config.contribution_inflation_adjusted);
}

test "parseProjectionsConfig rejects negative annual_contribution" {
    const data =
        \\#!srfv1
        \\type::config,annual_contribution:num:-50000
    ;
    const config = parseProjectionsConfig(data);
    // Negative dropped; default zero retained.
    try std.testing.expectApproxEqAbs(@as(f64, 0), config.annual_contribution, 0.01);
}

test "parseProjectionsConfig parses target_spending" {
    const data =
        \\#!srfv1
        \\type::config,target_spending:num:80000
        \\type::config,target_spending_inflation_adjusted:bool:false
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectApproxEqAbs(@as(f64, 80_000), config.target_spending.?, 0.01);
    try std.testing.expect(!config.target_spending_inflation_adjusted);
}

test "parseProjectionsConfig rejects negative target_spending" {
    const data =
        \\#!srfv1
        \\type::config,target_spending:num:-1000
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqual(@as(?f64, null), config.target_spending);
}

test "parseProjectionsConfig benchmark defaults are SPY and AGG" {
    const config = parseProjectionsConfig(null);
    try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
    try std.testing.expectEqualStrings("AGG", config.benchmark_bond);
}

test "parseProjectionsConfig parses benchmark_stock and benchmark_bond" {
    const data =
        \\#!srfv1
        \\type::config,benchmark_stock::VTI
        \\type::config,benchmark_bond::BND
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqualStrings("VTI", config.benchmark_stock);
    try std.testing.expectEqualStrings("BND", config.benchmark_bond);
}

test "parseProjectionsConfig partial benchmark override falls back to default" {
    // Only benchmark_stock configured — benchmark_bond stays at default.
    const data =
        \\#!srfv1
        \\type::config,benchmark_stock::QQQ
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqualStrings("QQQ", config.benchmark_stock);
    try std.testing.expectEqualStrings("AGG", config.benchmark_bond);
}

test "parseProjectionsConfig rejects oversized benchmark symbol" {
    // 17-char symbol exceeds the 16-byte buffer; should be ignored.
    const data =
        \\#!srfv1
        \\type::config,benchmark_stock::ABCDEFGHIJKLMNOPQ
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqualStrings("SPY", config.benchmark_stock);
}

test "parseProjectionsConfig parses both retirement_age and retirement_at" {
    // Both fields can be set in the file; resolver picks retirement_at.
    // Parsing just stores both raw.
    const data =
        \\#!srfv1
        \\type::config,retirement_age:num:65
        \\type::config,retirement_at::2036-07-01
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqual(@as(?u16, 65), config.retirement_age);
    try std.testing.expect(config.retirement_at != null);
}

test "resolveRetirement: retirement_at in future" {
    var config = UserConfig{};
    config.retirement_at = Date.fromYmd(2036, 7, 1);
    const today = Date.fromYmd(2026, 7, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(@as(u16, 10), r.accumulation_years);
    try std.testing.expect(r.date != null);
    try std.testing.expect(r.date.?.eql(Date.fromYmd(2036, 7, 1)));
    try std.testing.expectEqual(.at_date, r.source);
}

test "resolveRetirement: retirement_at in past degrades to none" {
    var config = UserConfig{};
    config.retirement_at = Date.fromYmd(2020, 1, 1);
    const today = Date.fromYmd(2026, 7, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(@as(u16, 0), r.accumulation_years);
    try std.testing.expect(r.date == null);
    try std.testing.expectEqual(.none, r.source);
}

test "resolveRetirement: retirement_age with birthday already passed this year" {
    // Born 1975-03-15; today 2025-06-01 (past 03-15 this year).
    // Target 65 → date 2040-03-15; accumulation_years = floor(years between today and 2040-03-15).
    var config = UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1975, 3, 15);
    config.retirement_age = 65;
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expect(r.date != null);
    try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
    try std.testing.expectEqual(.at_age, r.source);
    // ~14.78 years → floor = 14
    try std.testing.expectEqual(@as(u16, 14), r.accumulation_years);
}

test "resolveRetirement: retirement_age with birthday still ahead this year" {
    // Born 1975-08-15; today 2025-06-01 (before 08-15 this year).
    // Target 65 → date 2040-08-15; ~15.21 years → floor = 15.
    var config = UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1975, 8, 15);
    config.retirement_age = 65;
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(@as(u16, 15), r.accumulation_years);
    try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 8, 15)));
}

test "resolveRetirement: retirement_age already past degrades to none" {
    var config = UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1965, 1, 1); // age ~60 in 2025
    config.retirement_age = 40; // already past
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(.none, r.source);
}

test "resolveRetirement: retirement_age with no birthdate degrades to none" {
    var config = UserConfig{};
    config.retirement_age = 65;
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(.none, r.source);
}

test "resolveRetirement: multi-person uses oldest birthdate" {
    // Person 1: born 1975-03-15 (oldest). Person 2: born 1980-06-15.
    // Target age 65 → date is for person 1: 2040-03-15.
    var config = UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1975, 3, 15);
    config.birthdates[1] = Date.fromYmd(1980, 6, 15);
    config.retirement_age = 65;
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
}

test "resolveRetirement: multi-person uses oldest regardless of order" {
    // Person 1 (slot 0) is the YOUNGER one. Resolver should still
    // pick slot 1 (the older) for the retirement date.
    var config = UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1980, 6, 15);
    config.birthdates[1] = Date.fromYmd(1975, 3, 15);
    config.retirement_age = 65;
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expect(r.date.?.eql(Date.fromYmd(2040, 3, 15)));
}

test "resolveRetirement: retirement_at wins when both set" {
    var config = UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1975, 3, 15);
    config.retirement_age = 65;
    config.retirement_at = Date.fromYmd(2030, 1, 1);
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(.at_date, r.source);
    try std.testing.expect(r.date.?.eql(Date.fromYmd(2030, 1, 1)));
}

test "resolveRetirement: none when neither field is set" {
    const config = UserConfig{};
    const today = Date.fromYmd(2025, 6, 1);
    const r = config.resolveRetirement(today);
    try std.testing.expectEqual(.none, r.source);
    try std.testing.expectEqual(@as(u16, 0), r.accumulation_years);
    try std.testing.expect(r.date == null);
}

test "resolveRetirement: retirement_age and retirement_at agree on same boundary" {
    // Configure retirement_at and retirement_age such that both
    // resolve to the same accumulation_years. retirement_at wins per
    // the rule, but the integer years should match.
    var c1 = UserConfig{};
    c1.retirement_at = Date.fromYmd(2036, 7, 1);
    var c2 = UserConfig{};
    c2.birthdate_count = 1;
    c2.birthdates[0] = Date.fromYmd(1971, 7, 1); // turns 65 on 2036-07-01
    c2.retirement_age = 65;

    const today = Date.fromYmd(2026, 7, 1);
    const r1 = c1.resolveRetirement(today);
    const r2 = c2.resolveRetirement(today);
    try std.testing.expectEqual(r1.accumulation_years, r2.accumulation_years);
    try std.testing.expect(r1.date.?.eql(r2.date.?));
}

// ── Two-phase simulation regression tests ──────────────────────

test "regression: findSafeWithdrawal(30, 1M, 0.75, 0.95) unchanged" {
    // Pin the post-refactor value of the canonical SWR call. If this
    // test ever fails, the two-phase refactor changed
    // distribution-only behavior — investigate before bumping the
    // golden value. Captured 2026-05-12.
    const r = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    // Use a tight band — the binary search has $1 precision, so
    // anything farther than a few dollars off is a real change.
    try std.testing.expect(r.annual_amount >= 38_000);
    try std.testing.expect(r.annual_amount <= 50_000);
    // Snapshot the exact value as well so we notice silent drift.
    // Actual value at refactor time was determined empirically.
    const expected = 44_036.0;
    try std.testing.expectApproxEqAbs(expected, r.annual_amount, 5.0);
}

test "regression: zero accumulation matches direct findSafeWithdrawal" {
    // Both wrappers go through `searchSafeWithdrawal`; with
    // accumulation_years=0 and zero contributions, the bracket
    // seeding and search loop are identical. Tolerance is 0
    // because the two paths execute the same code with the same
    // inputs — any drift here means the unification broke.
    const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    const via_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 0, 0, true, 0);
    try std.testing.expectEqual(direct.annual_amount, via_accum.annual_amount);
    try std.testing.expectEqual(direct.confidence, via_accum.confidence);
    try std.testing.expectEqual(direct.withdrawal_rate, via_accum.withdrawal_rate);
}

test "two-phase: 10y accumulation with $100k/yr contributions raises post-accum portfolio" {
    // Compare the median portfolio at year 10 with vs without
    // contributions. Contributions should produce a meaningfully
    // higher median.
    const allocator = std.testing.allocator;

    const params_no_contrib: SimParams = .{
        .initial_value = 1_000_000,
        .stock_pct = 0.75,
        .annual_spending = 0,
        .distribution_years = 30,
        .accumulation_years = 10,
        .annual_contribution = 0,
    };
    const params_with_contrib: SimParams = .{
        .initial_value = 1_000_000,
        .stock_pct = 0.75,
        .annual_spending = 0,
        .distribution_years = 30,
        .accumulation_years = 10,
        .annual_contribution = 100_000,
    };

    const bands_no = try computePercentileBandsParams(allocator, params_no_contrib);
    defer allocator.free(bands_no);
    const bands_with = try computePercentileBandsParams(allocator, params_with_contrib);
    defer allocator.free(bands_with);

    // Both bands span 40 years (10 accum + 30 dist) → 41 entries.
    try std.testing.expectEqual(@as(usize, 41), bands_no.len);
    try std.testing.expectEqual(@as(usize, 41), bands_with.len);

    // Year-0 starts the same in both.
    try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_no[0].p50, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands_with[0].p50, 1.0);

    // At the retirement boundary (year 10), with-contributions
    // median should exceed without by significantly more than
    // 10 × $100k (compounding helps).
    try std.testing.expect(bands_with[10].p50 > bands_no[10].p50 + 1_000_000);
}

test "two-phase: nominal contributions produce lower year-10 median than CPI-adjusted" {
    // CPI-adjusted contributions grow over time; nominal stay flat.
    // Over 10 years, CPI-adjusted should accumulate more.
    const allocator = std.testing.allocator;
    const cpi_adj: SimParams = .{
        .initial_value = 1_000_000,
        .stock_pct = 0.75,
        .annual_spending = 0,
        .distribution_years = 30,
        .accumulation_years = 10,
        .annual_contribution = 100_000,
        .contribution_inflation_adjusted = true,
    };
    const nominal: SimParams = .{
        .initial_value = 1_000_000,
        .stock_pct = 0.75,
        .annual_spending = 0,
        .distribution_years = 30,
        .accumulation_years = 10,
        .annual_contribution = 100_000,
        .contribution_inflation_adjusted = false,
    };
    const b_cpi = try computePercentileBandsParams(allocator, cpi_adj);
    defer allocator.free(b_cpi);
    const b_nom = try computePercentileBandsParams(allocator, nominal);
    defer allocator.free(b_nom);
    // Median at year 10 should be higher with CPI-adjusted (over
    // any sufficiently inflationary historical window the diff is
    // positive; CPI is non-negative on the long term).
    try std.testing.expect(b_cpi[10].p50 >= b_nom[10].p50);
}

test "two-phase: SWR with accumulation exceeds same-portfolio direct SWR" {
    // 10 years of $100k contributions on top of $1M should produce
    // a higher safe withdrawal than $1M alone over a 30-year
    // distribution at the same confidence.
    const direct = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95, &.{});
    const with_accum = findSafeWithdrawalWithAccumulation(30, 1_000_000, 0.75, 0.95, &.{}, 10, 100_000, true, 0);
    try std.testing.expect(with_accum.annual_amount > direct.annual_amount);
}

test "simulateTwoPhase: null-buf and non-null-buf agree on verdict" {
    // Locks in the invariant that calling simulateTwoPhase with
    // null produces the same survival bit as calling it with a
    // path buffer. This is the load-bearing equivalence that lets
    // `successRateParams` use the cheaper null-buf path while
    // `runAllCyclesParams` uses the path-storing version, with
    // both producing the same answer about whether a given cycle
    // failed.
    //
    // Cover three regimes: clear survivor, clear failure, and a
    // marginal case driven by an extreme spending level.
    const cases = [_]struct {
        params: SimParams,
        starts: []const usize,
    }{
        .{
            // Clear survivor: zero spending.
            .params = .{
                .initial_value = 1_000_000,
                .stock_pct = 0.75,
                .annual_spending = 0,
                .distribution_years = 30,
            },
            .starts = &.{ 0, 25, 50, 75 },
        },
        .{
            // Clear failure: spend $200k/yr from $500k, 30 years.
            .params = .{
                .initial_value = 500_000,
                .stock_pct = 0.75,
                .annual_spending = 200_000,
                .distribution_years = 30,
            },
            .starts = &.{ 0, 25, 50, 75 },
        },
        .{
            // Marginal: 10y accumulation then 30y of moderate spend.
            .params = .{
                .initial_value = 1_000_000,
                .stock_pct = 0.75,
                .annual_spending = 60_000,
                .distribution_years = 30,
                .accumulation_years = 10,
                .annual_contribution = 50_000,
            },
            .starts = &.{ 0, 30, 60 },
        },
    };

    var buf: [101]f64 = undefined; // max total ≈ 50 + 50, slack
    for (cases) |case| {
        for (case.starts) |start| {
            const total = case.params.totalYears();
            std.debug.assert(total + 1 <= buf.len);
            const verdict_null = simulateTwoPhase(null, shiller.annual_returns, start, case.params);
            const verdict_buf = simulateTwoPhase(buf[0 .. total + 1], shiller.annual_returns, start, case.params);
            try std.testing.expectEqual(verdict_null, verdict_buf);
        }
    }
}

// ── findEarliestRetirement tests ───────────────────────────────

test "findEarliestRetirement: feasible at N=0 returns 0" {
    // $10M portfolio, $40k/yr spending, 30y distribution, 95%
    // confidence — feasible immediately (1.6× the 4% rule).
    const allocator = std.testing.allocator;
    const r = try findEarliestRetirement(
        allocator,
        10_000_000, // initial_value
        0.75, // stock_pct
        0, // annual_contribution
        true,
        40_000, // target_spending
        true,
        30, // distribution_years
        0.95, // confidence
        &.{},
        50, // max_years
        0, // expense_ratio
    );
    try std.testing.expectEqual(@as(?u16, 0), r.accumulation_years);
}

test "findEarliestRetirement: unreachable returns null" {
    // $1M portfolio, $1M/yr spending, no contributions: never
    // feasible. Returns null.
    const allocator = std.testing.allocator;
    const r = try findEarliestRetirement(
        allocator,
        1_000_000,
        0.75,
        0, // no contributions
        true,
        1_000_000, // target spending = entire portfolio every year
        true,
        30,
        0.95,
        &.{},
        50,
        0, // expense_ratio
    );
    try std.testing.expectEqual(@as(?u16, null), r.accumulation_years);
}

test "findEarliestRetirement: longer distribution shifts retirement later or unchanged" {
    // Same setup, just two horizons.
    const allocator = std.testing.allocator;
    const short = try findEarliestRetirement(
        allocator,
        1_000_000,
        0.75,
        50_000,
        true,
        80_000,
        true,
        20, // 20-year distribution
        0.95,
        &.{},
        50,
        0, // expense_ratio
    );
    const long = try findEarliestRetirement(
        allocator,
        1_000_000,
        0.75,
        50_000,
        true,
        80_000,
        true,
        45, // 45-year distribution
        0.95,
        &.{},
        50,
        0, // expense_ratio
    );
    if (short.accumulation_years != null and long.accumulation_years != null) {
        try std.testing.expect(long.accumulation_years.? >= short.accumulation_years.?);
    }
}

test "findEarliestRetirement: result includes portfolio statistics" {
    const allocator = std.testing.allocator;
    const r = try findEarliestRetirement(
        allocator,
        2_000_000,
        0.75,
        100_000,
        true,
        80_000,
        true,
        30,
        0.95,
        &.{},
        50,
        0, // expense_ratio
    );
    if (r.accumulation_years) |n| {
        if (n > 0) {
            // Median portfolio at retirement should be >= initial
            // value (we accumulate before drawing down).
            try std.testing.expect(r.median_at_retirement >= 1_500_000);
            // p10 ≤ p50 ≤ p90.
            try std.testing.expect(r.p10_at_retirement <= r.median_at_retirement);
            try std.testing.expect(r.median_at_retirement <= r.p90_at_retirement);
        }
    }
}

// ── ResolvedRetirement formatter tests ─────────────────────────

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

test "fmtRetirementLine: at_date case" {
    var buf: [128]u8 = undefined;
    const line = retirementLineForTest(&buf, .{
        .accumulation_years = 10,
        .date = Date.fromYmd(2036, 7, 1),
        .source = .at_date,
    });
    try std.testing.expectEqualStrings("Years until possible retirement: 10 (2036-07-01)", line);
}

test "fmtRetirementLine: at_age case" {
    var buf: [128]u8 = undefined;
    const line = retirementLineForTest(&buf, .{
        .accumulation_years = 14,
        .date = Date.fromYmd(2040, 3, 15),
        .source = .at_age,
    });
    try std.testing.expectEqualStrings("Years until possible retirement: 14 (2040-03-15)", line);
}

/// Test-only adapter to avoid dragging the views/projections.zig
/// module into this file's import surface. Mirrors
/// `views.fmtRetirementLine` exactly; if the formatter ever moves,
/// update both.
fn retirementLineForTest(buf: []u8, resolved: ResolvedRetirement) []const u8 {
    if (resolved.source == .none) {
        return std.fmt.bufPrint(buf, "Years until possible retirement: none", .{}) catch "Years until possible retirement: none";
    }
    var date_buf: [10]u8 = undefined;
    const date_str = if (resolved.date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??";
    return std.fmt.bufPrint(buf, "Years until possible retirement: {d} ({s})", .{
        resolved.accumulation_years,
        date_str,
    }) catch "Years until possible retirement: ?";
}

// ── pickPromotedCell tests ─────────────────────────────────────

test "pickPromotedCell: longest horizon selected when oldest stays under cap" {
    var config = UserConfig{};
    config.horizon_count = 3;
    config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12); // ~age 45 in 2026
    const today = Date.fromYmd(2026, 5, 12);
    const confs = [_]f64{ 0.90, 0.95, 0.99 };
    const pc = pickPromotedCell(&config, today, &confs).?;
    // Longest is 50; 45 + 50 = 95 < 100 → 50yr horizon picked.
    try std.testing.expectEqual(@as(usize, 2), pc.horizon_index);
    try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99% default
    try std.testing.expect(!pc.explicit);
}

test "pickPromotedCell: longest horizon overshoots, second-longest selected" {
    var config = UserConfig{};
    config.horizon_count = 3;
    config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1968, 4, 12); // ~age 58 in 2026
    const today = Date.fromYmd(2026, 5, 12);
    const confs = [_]f64{ 0.90, 0.95, 0.99 };
    const pc = pickPromotedCell(&config, today, &confs).?;
    // Longest is 50; 58 + 50 = 108 >= 100 → skip.
    // Next is 35; 58 + 35 = 93 < 100 → pick.
    try std.testing.expectEqual(@as(u16, 35), config.horizons[pc.horizon_index]);
    try std.testing.expectEqual(@as(usize, 2), pc.confidence_index);
}

test "pickPromotedCell: all horizons overshoot, fall through to shortest" {
    var config = UserConfig{};
    config.horizon_count = 3;
    config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1948, 4, 12); // ~age 78 in 2026
    const today = Date.fromYmd(2026, 5, 12);
    const confs = [_]f64{ 0.90, 0.95, 0.99 };
    const pc = pickPromotedCell(&config, today, &confs).?;
    // All overshoot 100. Shortest is 25 → pick it (fuck-it branch).
    try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
}

test "pickPromotedCell: explicit retirement_target wins regardless of length" {
    var config = UserConfig{};
    config.horizon_count = 3;
    config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
    // Annotate the SHORTEST horizon — overrides default rule which
    // would pick the longest.
    config.horizon_targets[0] = 95;
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12);
    const today = Date.fromYmd(2026, 5, 12);
    const confs = [_]f64{ 0.90, 0.95, 0.99 };
    const pc = pickPromotedCell(&config, today, &confs).?;
    try std.testing.expectEqual(@as(u16, 25), config.horizons[pc.horizon_index]);
    try std.testing.expectEqual(@as(usize, 1), pc.confidence_index); // 95% → index 1
    try std.testing.expect(pc.explicit);
}

test "pickPromotedCell: no birthdates falls through to longest horizon" {
    var config = UserConfig{};
    config.horizon_count = 3;
    config.horizons = .{ 25, 35, 50 } ++ @as([UserConfig.max_horizons - 3]u16, @splat(0));
    const today = Date.fromYmd(2026, 5, 12);
    const confs = [_]f64{ 0.90, 0.95, 0.99 };
    const pc = pickPromotedCell(&config, today, &confs).?;
    try std.testing.expectEqual(@as(u16, 50), config.horizons[pc.horizon_index]);
    try std.testing.expectEqual(@as(usize, 2), pc.confidence_index); // 99%
}

test "pickPromotedCell: zero horizons returns null" {
    var config = UserConfig{};
    config.horizon_count = 0;
    const today = Date.fromYmd(2026, 5, 12);
    const confs = [_]f64{ 0.90, 0.95, 0.99 };
    try std.testing.expect(pickPromotedCell(&config, today, &confs) == null);
}

test "parseProjectionsConfig: retirement_target on horizon record" {
    const data =
        \\#!srfv1
        \\type::config,horizon:num:25
        \\type::config,horizon:num:35,retirement_target:num:95
        \\type::config,horizon:num:50
    ;
    const config = parseProjectionsConfig(data);
    try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
    try std.testing.expectEqual(@as(u8, 95), config.horizon_targets[1]);
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
}

test "parseProjectionsConfig: retirement_target on horizon_age survives resolution" {
    const data =
        \\#!srfv1
        \\type::config,horizon_age:num:90,retirement_target:num:99
        \\type::birthdate,date::1975-01-01
    ;
    var config = parseProjectionsConfig(data);
    try std.testing.expectEqual(@as(u8, 99), config.horizon_age_targets[0]);
    // Resolve: oldest age in 2025 is 50 → horizon 40.
    try config.resolveHorizonAges(Date.fromYmd(2025, 6, 15));
    try std.testing.expectEqual(@as(u8, 1), config.horizon_count);
    try std.testing.expectEqual(@as(u16, 40), config.horizons[0]);
    try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[0]);
}

test "parseProjectionsConfig: invalid retirement_target value dropped silently per record" {
    const data =
        \\#!srfv1
        \\type::config,horizon:num:25
        \\type::config,horizon:num:35,retirement_target:num:80
        \\type::config,horizon:num:50,retirement_target:num:99
    ;
    const config = parseProjectionsConfig(data);
    // Record with retirement_target:80 keeps the horizon but drops
    // the invalid annotation. The 99 on the third horizon is the
    // ONLY valid annotation, so it stays.
    try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
    try std.testing.expectEqual(@as(u8, 99), config.horizon_targets[2]);
}

test "parseProjectionsConfig: multiple retirement_target annotations all dropped" {
    const data =
        \\#!srfv1
        \\type::config,horizon:num:25,retirement_target:num:95
        \\type::config,horizon:num:35,retirement_target:num:99
        \\type::config,horizon:num:50
    ;
    const config = parseProjectionsConfig(data);
    // Validation post-pass: > 1 annotation → drop them all.
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[0]);
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[1]);
    try std.testing.expectEqual(@as(u8, 0), config.horizon_targets[2]);
}

test "validRetirementTarget: 90/95/99 pass, others fail" {
    try std.testing.expectEqual(@as(?u8, 90), validRetirementTarget(90));
    try std.testing.expectEqual(@as(?u8, 95), validRetirementTarget(95));
    try std.testing.expectEqual(@as(?u8, 99), validRetirementTarget(99));
    try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(null));
    try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(0));
    try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(85));
    try std.testing.expectEqual(@as(?u8, null), validRetirementTarget(100));
}

// ── oldestBirthdate / oldestAge tests ──────────────────────────

test "oldestBirthdate: no birthdates returns null" {
    const config = UserConfig{};
    try std.testing.expectEqual(@as(?Date, null), config.oldestBirthdate());
}

test "oldestBirthdate: single birthdate returns it" {
    var config = UserConfig{};
    config.birthdate_count = 1;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12);
    const result = config.oldestBirthdate();
    try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12)));
}

test "oldestBirthdate: multi-person picks earliest date" {
    var config = UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1983, 9, 8);
    config.birthdates[1] = Date.fromYmd(1981, 4, 12); // older
    const result = config.oldestBirthdate();
    try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12)));
}

test "oldestBirthdate: multi-person regardless of slot order" {
    var config = UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12); // older
    config.birthdates[1] = Date.fromYmd(1983, 9, 8);
    const result = config.oldestBirthdate();
    try std.testing.expect(result.?.eql(Date.fromYmd(1981, 4, 12)));
}

test "oldestAge: no birthdates returns 0" {
    const config = UserConfig{};
    const as_of = Date.fromYmd(2026, 5, 12);
    try std.testing.expectEqual(@as(u16, 0), config.oldestAge(as_of));
}

test "oldestAge: derives whole years from oldest birthdate" {
    var config = UserConfig{};
    config.birthdate_count = 2;
    config.birthdates[0] = Date.fromYmd(1981, 4, 12);
    config.birthdates[1] = Date.fromYmd(1983, 9, 8);
    // 1981-04-12 → 2026-05-12 spans 45 full years.
    const as_of = Date.fromYmd(2026, 5, 12);
    try std.testing.expectEqual(@as(u16, 45), config.oldestAge(as_of));
}

// ── runProjectionGrid tests ────────────────────────────────────

/// Free a `ProjectionData` produced by `runProjectionGrid`. Used by
/// the tests below to keep their cleanup blocks tidy.
fn freeProjectionData(allocator: std.mem.Allocator, data: ProjectionData) void {
    allocator.free(data.withdrawals);
    for (data.bands) |b| {
        if (b) |slice| allocator.free(slice);
    }
    allocator.free(data.bands);
}

test "runProjectionGrid: structure and indexing" {
    const allocator = std.testing.allocator;
    const horizons = [_]u16{ 20, 30 };
    const conf = [_]f64{ 0.95, 0.99 };

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    // 2 horizons × 2 confidence levels = 4 withdrawal results.
    try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len);
    // 2 bands (one per horizon).
    try std.testing.expectEqual(@as(usize, 2), data.bands.len);
    // ci_99 is the last (highest) confidence index.
    try std.testing.expectEqual(@as(usize, 1), data.ci_99);
}

test "runProjectionGrid: withdrawal monotonicity along confidence axis" {
    // Same horizon, lower confidence → higher allowed spending.
    // Indexing: withdrawals[ci * horizons.len + hi].
    const allocator = std.testing.allocator;
    const horizons = [_]u16{30};
    const conf = [_]f64{ 0.90, 0.95, 0.99 };

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    const w_90 = data.withdrawals[0 * horizons.len + 0].annual_amount;
    const w_95 = data.withdrawals[1 * horizons.len + 0].annual_amount;
    const w_99 = data.withdrawals[2 * horizons.len + 0].annual_amount;
    try std.testing.expect(w_90 >= w_95);
    try std.testing.expect(w_95 >= w_99);
}

test "runProjectionGrid: withdrawal monotonicity along horizon axis" {
    // Same confidence, longer horizon → lower allowed spending.
    const allocator = std.testing.allocator;
    const horizons = [_]u16{ 20, 30, 45 };
    const conf = [_]f64{0.95};

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    const w_20 = data.withdrawals[0 * horizons.len + 0].annual_amount;
    const w_30 = data.withdrawals[0 * horizons.len + 1].annual_amount;
    const w_45 = data.withdrawals[0 * horizons.len + 2].annual_amount;
    try std.testing.expect(w_20 >= w_30);
    try std.testing.expect(w_30 >= w_45);
}

test "runProjectionGrid: distribution-only band length is horizon + 1" {
    const allocator = std.testing.allocator;
    const horizons = [_]u16{ 20, 30 };
    const conf = [_]f64{ 0.95, 0.99 };

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    // band[0] covers horizons[0] = 20 → 21 entries; band[1] covers
    // horizons[1] = 30 → 31 entries.
    try std.testing.expectEqual(@as(usize, 21), data.bands[0].?.len);
    try std.testing.expectEqual(@as(usize, 31), data.bands[1].?.len);
}

test "runProjectionGrid: with-accumulation band length includes accumulation_years" {
    const allocator = std.testing.allocator;
    const horizons = [_]u16{30};
    const conf = [_]f64{0.95};

    // 10 years of accumulation + 30 years distribution → 41 entries.
    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
    defer freeProjectionData(allocator, data);

    try std.testing.expectEqual(@as(usize, 41), data.bands[0].?.len);
}

test "runProjectionGrid: bands are p10 ≤ p25 ≤ p50 ≤ p75 ≤ p90 at every year" {
    const allocator = std.testing.allocator;
    const horizons = [_]u16{30};
    const conf = [_]f64{0.95};

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    for (data.bands[0].?) |b| {
        try std.testing.expect(b.p10 <= b.p25);
        try std.testing.expect(b.p25 <= b.p50);
        try std.testing.expect(b.p50 <= b.p75);
        try std.testing.expect(b.p75 <= b.p90);
    }
}

test "runProjectionGrid: year 0 in every band equals total_value" {
    const allocator = std.testing.allocator;
    const horizons = [_]u16{ 20, 30 };
    const conf = [_]f64{0.95};

    const total_value: f64 = 2_000_000;
    const data = try runProjectionGrid(allocator, &horizons, &conf, total_value, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    for (data.bands) |b_opt| {
        const b = b_opt.?;
        try std.testing.expectApproxEqAbs(total_value, b[0].p10, 1.0);
        try std.testing.expectApproxEqAbs(total_value, b[0].p50, 1.0);
        try std.testing.expectApproxEqAbs(total_value, b[0].p90, 1.0);
    }
}

test "runProjectionGrid: bands are computed at the highest-confidence withdrawal" {
    // The chart anchors on `ci_99` — the LAST entry in
    // `confidence_levels` — by feeding that withdrawal rate into
    // `computePercentileBandsParams`. With confidence_levels =
    // {.90, .95, .99}, the bands should reflect spending at 99%
    // (the smallest, most-conservative withdrawal).
    //
    // Verification: re-running the band computation with the
    // 99%-confidence withdrawal should produce identical bands.
    const allocator = std.testing.allocator;
    const horizons = [_]u16{30};
    const conf = [_]f64{ 0.90, 0.95, 0.99 };

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    const wr_99 = data.withdrawals[data.ci_99 * horizons.len + 0];
    const expected = try computePercentileBandsParams(allocator, .{
        .initial_value = 1_000_000,
        .stock_pct = 0.75,
        .annual_spending = wr_99.annual_amount,
        .distribution_years = 30,
    });
    defer allocator.free(expected);

    const actual = data.bands[0].?;
    try std.testing.expectEqual(expected.len, actual.len);
    for (expected, actual) |exp, act| {
        try std.testing.expectEqual(exp.year, act.year);
        try std.testing.expectEqual(exp.p10, act.p10);
        try std.testing.expectEqual(exp.p50, act.p50);
        try std.testing.expectEqual(exp.p90, act.p90);
    }
}

test "runProjectionGrid: accumulation passes through to both withdrawals and bands" {
    // Same horizon, same confidence, same starting portfolio:
    // 10 years of $50k contributions should produce a meaningfully
    // higher safe withdrawal than zero accumulation (the
    // post-accumulation portfolio is bigger), AND the bands should
    // be longer (accumulation_years + distribution_years + 1).
    const allocator = std.testing.allocator;
    const horizons = [_]u16{30};
    const conf = [_]f64{0.95};

    const dist_only = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, dist_only);

    const with_accum = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 10, 50_000, true, 0);
    defer freeProjectionData(allocator, with_accum);

    // SWR with 10y of contributions on top should exceed SWR
    // without.
    try std.testing.expect(with_accum.withdrawals[0].annual_amount > dist_only.withdrawals[0].annual_amount);

    // Band length differs by exactly accumulation_years.
    try std.testing.expectEqual(dist_only.bands[0].?.len + 10, with_accum.bands[0].?.len);
}

test "runProjectionGrid: zero horizons produces empty results without crashing" {
    const allocator = std.testing.allocator;
    const horizons = [_]u16{};
    const conf = [_]f64{ 0.95, 0.99 };

    const data = try runProjectionGrid(allocator, &horizons, &conf, 1_000_000, 0.75, &.{}, 0, 0, true, 0);
    defer freeProjectionData(allocator, data);

    try std.testing.expectEqual(@as(usize, 0), data.withdrawals.len);
    try std.testing.expectEqual(@as(usize, 0), data.bands.len);
}
