//! Portfolio timeline analytics — pure compute over snapshot history.
//!
//! This module takes pre-loaded portfolio snapshots (see `src/history.zig`
//! for the IO layer that produces them) and reduces them to time-series
//! data for display or export. Nothing here touches the filesystem, the
//! network, or a writer — that's by design, so the logic can be tested
//! exhaustively with fixture data.
//!
//! Typical flow:
//!   snapshots : []const Snapshot      <- history.loadHistoryDir(...)
//!        │
//!        ▼
//!   buildSeries(snapshots)            -> TimelineSeries (sorted by date)
//!        │
//!        ▼
//!   filterByDate(series, since, until) (optional)
//!        │
//!        ▼
//!   extractMetric(series, .net_worth) -> []MetricPoint for rendering
//!
//! For rollup generation, `buildRollupRecords` emits a flat slice suitable
//! for `srf.fmt` without any of the per-lot detail — the rollup is a
//! summary cache, not a replacement for the per-day snapshot files.

const std = @import("std");
const Date = @import("../Date.zig");
const snapshot = @import("../models/snapshot.zig");
const valuation = @import("valuation.zig");
const HistoricalPeriod = valuation.HistoricalPeriod;

// ── Public types ─────────────────────────────────────────────

/// A single point on the portfolio timeline. Totals are always present
/// (they're the three `kind::total` rows every snapshot emits); the
/// per-account / per-tax-type maps are only populated when the source
/// snapshot included analysis breakdowns.
///
/// Values are dollar amounts. Weights aren't stored — callers can
/// compute them cheaply from the totals when rendering.
pub const TimelinePoint = struct {
    as_of_date: Date,
    net_worth: f64,
    liquid: f64,
    illiquid: f64,
    /// Per-account totals. Empty when the source snapshot had no
    /// analysis breakdown (e.g. backfilled-from-.txt snapshots).
    /// Keys are account names; values are dollar totals.
    accounts: []const NamedValue,
    /// Per-tax-type totals. Same caveat as `accounts`.
    tax_types: []const NamedValue,
    /// Where this point came from. `.snapshot` (default) means a
    /// native `*-portfolio.srf` file; `.imported` means a
    /// `imported_values.srf` row that was merged in. Imported
    /// points carry only `liquid` (illiquid/net_worth are zero,
    /// accounts/tax_types are empty).
    source: Source = .snapshot,

    pub const Source = enum { snapshot, imported };

    /// Deinit releases only the owned slices (`accounts`, `tax_types`).
    /// The strings inside those slices are borrowed from the source
    /// Snapshot's arena and must not be freed here.
    pub fn deinit(self: TimelinePoint, allocator: std.mem.Allocator) void {
        allocator.free(self.accounts);
        allocator.free(self.tax_types);
    }
};

pub const NamedValue = struct {
    name: []const u8,
    value: f64,
};

/// An ordered series of TimelinePoint sorted ascending by as_of_date.
/// Duplicates by date shouldn't happen (snapshot filenames enforce one
/// per day) but if they do the caller's last-write-wins.
pub const TimelineSeries = struct {
    points: []TimelinePoint,
    allocator: std.mem.Allocator,

    pub fn deinit(self: TimelineSeries) void {
        for (self.points) |p| p.deinit(self.allocator);
        self.allocator.free(self.points);
    }
};

/// Which metric to extract for single-series display.
pub const Metric = enum {
    net_worth,
    liquid,
    illiquid,

    pub fn label(self: Metric) []const u8 {
        return switch (self) {
            .net_worth => "Net Worth",
            .liquid => "Liquid",
            .illiquid => "Illiquid",
        };
    }
};

/// One row of the extracted single-metric series. Used as input to
/// renderers and for mathematical operations (diffs, deltas, sparklines).
pub const MetricPoint = struct {
    date: Date,
    value: f64,
};

// ── Core construction ────────────────────────────────────────

/// Build a TimelineSeries from a slice of snapshots.
///
/// Input snapshots are NOT required to be sorted; this function sorts
/// by `as_of_date` ascending. Each returned point's `accounts` and
/// `tax_types` slices borrow strings from the source Snapshot's arena
/// but own their own outer slice (freed by TimelinePoint.deinit).
pub fn buildSeries(
    allocator: std.mem.Allocator,
    snapshots: []const snapshot.Snapshot,
) !TimelineSeries {
    var points = try allocator.alloc(TimelinePoint, snapshots.len);
    errdefer {
        // On error, free any already-populated points' nested slices.
        allocator.free(points);
    }

    var populated: usize = 0;
    errdefer {
        for (points[0..populated]) |p| p.deinit(allocator);
    }

    for (snapshots, 0..) |snap, idx| {
        points[idx] = try snapshotToPoint(allocator, snap);
        populated = idx + 1;
    }

    std.mem.sort(TimelinePoint, points, {}, lessByDate);

    return .{ .points = points, .allocator = allocator };
}

fn lessByDate(_: void, a: TimelinePoint, b: TimelinePoint) bool {
    return a.as_of_date.lessThan(b.as_of_date);
}

/// Derive a TimelinePoint from a single Snapshot. Pure — no IO.
///
/// Exposed for testability. `buildSeries` is the usual entry point.
pub fn snapshotToPoint(
    allocator: std.mem.Allocator,
    snap: snapshot.Snapshot,
) !TimelinePoint {
    var nw: f64 = 0;
    var liq: f64 = 0;
    var ill: f64 = 0;
    for (snap.totals) |t| {
        if (std.mem.eql(u8, t.scope, "net_worth")) nw = t.value;
        if (std.mem.eql(u8, t.scope, "liquid")) liq = t.value;
        if (std.mem.eql(u8, t.scope, "illiquid")) ill = t.value;
    }

    const accts = try allocator.alloc(NamedValue, snap.accounts.len);
    errdefer allocator.free(accts);
    for (snap.accounts, 0..) |a, i| {
        accts[i] = .{ .name = a.name, .value = a.value };
    }

    const tts = try allocator.alloc(NamedValue, snap.tax_types.len);
    errdefer allocator.free(tts);
    for (snap.tax_types, 0..) |t, i| {
        tts[i] = .{ .name = t.label, .value = t.value };
    }

    return .{
        .as_of_date = snap.meta.as_of_date,
        .net_worth = nw,
        .liquid = liq,
        .illiquid = ill,
        .accounts = accts,
        .tax_types = tts,
        .source = .snapshot,
    };
}

// ── Merged series construction ───────────────────────────────

/// Build a TimelineSeries by merging native portfolio snapshots
/// with imported_values.srf rows. On overlapping dates, snapshots
/// take precedence (higher fidelity — they carry illiquid and
/// breakdowns).
///
/// Imported-only points produce TimelinePoint records with
/// `liquid` set, `illiquid = 0`, `net_worth = liquid`,
/// `accounts = &.{}`, `tax_types = &.{}`, and `source = .imported`.
/// Callers that need to skip imported-only points (e.g. for
/// illiquid/net_worth rendering) inspect `source`.
///
/// Returned series is sorted ascending by date.
pub fn buildMergedSeries(
    allocator: std.mem.Allocator,
    snapshots: []const snapshot.Snapshot,
    imported: []const ImportedHistoryPoint,
) !TimelineSeries {
    // First, collect the snapshot dates so we can skip imports
    // that overlap.
    var snapshot_days: std.AutoHashMap(i32, void) = .init(allocator);
    defer snapshot_days.deinit();
    for (snapshots) |snap| {
        try snapshot_days.put(snap.meta.as_of_date.days, {});
    }

    var points: std.ArrayList(TimelinePoint) = .empty;
    errdefer {
        for (points.items) |p| p.deinit(allocator);
        points.deinit(allocator);
    }

    // Snapshots: full-fidelity points.
    for (snapshots) |snap| {
        const tp = try snapshotToPoint(allocator, snap);
        try points.append(allocator, tp);
    }

    // Imports: only those whose date isn't already covered.
    for (imported) |ip| {
        if (snapshot_days.contains(ip.date.days)) continue;
        try points.append(allocator, .{
            .as_of_date = ip.date,
            .net_worth = ip.liquid,
            .liquid = ip.liquid,
            .illiquid = 0,
            .accounts = &.{},
            .tax_types = &.{},
            .source = .imported,
        });
    }

    const slice = try points.toOwnedSlice(allocator);
    std.mem.sort(TimelinePoint, slice, {}, lessByDate);

    return .{ .points = slice, .allocator = allocator };
}

/// Minimal cross-module type for `buildMergedSeries`. Mirrors the
/// public shape of `data/imported_values.zig:HistoryPoint` without
/// pulling that module into the analytics layer (analytics is
/// kept import-light to stay testable in isolation).
pub const ImportedHistoryPoint = struct {
    date: Date,
    liquid: f64,
};

// ── Filters ──────────────────────────────────────────────────

/// Return the subset of `points` whose `as_of_date` falls within the
/// inclusive `[since, until]` range. Either bound may be null to leave
/// that end open. The resulting slice is newly allocated; caller owns.
///
/// This does NOT free the input points — the caller remains responsible
/// for the original TimelineSeries.
pub fn filterByDate(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
    since: ?Date,
    until: ?Date,
) ![]TimelinePoint {
    var kept: std.ArrayList(TimelinePoint) = .empty;
    errdefer kept.deinit(allocator);

    for (points) |p| {
        if (since) |s| if (p.as_of_date.lessThan(s)) continue;
        if (until) |u| if (u.lessThan(p.as_of_date)) continue;
        try kept.append(allocator, p);
    }

    return kept.toOwnedSlice(allocator);
}

// ── Metric extraction ────────────────────────────────────────

/// Extract a single top-level metric into a flat `[]MetricPoint` ready
/// for rendering or statistical analysis. Result is caller-owned.
pub fn extractMetric(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
    metric: Metric,
) ![]MetricPoint {
    var out = try allocator.alloc(MetricPoint, points.len);
    for (points, 0..) |p, i| {
        out[i] = .{
            .date = p.as_of_date,
            .value = switch (metric) {
                .net_worth => p.net_worth,
                .liquid => p.liquid,
                .illiquid => p.illiquid,
            },
        };
    }
    return out;
}

/// Which collection of per-row named values on `TimelinePoint` to project.
pub const NamedSeriesSource = enum { accounts, tax_types };

/// Extract a single-metric series for a named row (an account or a
/// tax-type label) from the timeline. Dates without the named row emit
/// `value = 0` rather than being skipped, so the returned slice has
/// `points.len` entries — suitable for stacked displays that need a row
/// per named entity per date.
///
/// Caller owns the returned slice.
pub fn extractNamedSeries(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
    source: NamedSeriesSource,
    name: []const u8,
) ![]MetricPoint {
    var out = try allocator.alloc(MetricPoint, points.len);
    for (points, 0..) |p, i| {
        const rows = switch (source) {
            .accounts => p.accounts,
            .tax_types => p.tax_types,
        };
        var v: f64 = 0;
        for (rows) |r| {
            if (std.mem.eql(u8, r.name, name)) {
                v = r.value;
                break;
            }
        }
        out[i] = .{ .date = p.as_of_date, .value = v };
    }
    return out;
}

// ── Deltas / statistics ──────────────────────────────────────

/// Summary statistics over a single-metric series.
pub const MetricStats = struct {
    first: f64,
    last: f64,
    min: f64,
    max: f64,
    /// `last - first`. Dollars, not percent.
    delta_abs: f64,
    /// `(last - first) / first` — or null when `first == 0` (division
    /// by zero); callers should render "n/a" or similar.
    delta_pct: ?f64,
};

/// Compute min/max/first/last/delta over a MetricPoint slice. Returns
/// null on empty input — every field would be meaningless otherwise.
pub fn computeStats(points: []const MetricPoint) ?MetricStats {
    if (points.len == 0) return null;

    var min_v = points[0].value;
    var max_v = points[0].value;
    for (points[1..]) |p| {
        if (p.value < min_v) min_v = p.value;
        if (p.value > max_v) max_v = p.value;
    }

    const first = points[0].value;
    const last = points[points.len - 1].value;
    const delta = last - first;

    return .{
        .first = first,
        .last = last,
        .min = min_v,
        .max = max_v,
        .delta_abs = delta,
        .delta_pct = if (first == 0) null else delta / first,
    };
}

// ── Rollup emission ──────────────────────────────────────────

/// A single row in `history/rollup.srf`. Deliberately slim: one record
/// per date, carrying the three totals only. Per-account and per-
/// tax-type detail stays in the per-day files.
///
/// The `kind` discriminator pattern is consistent with the snapshot
/// format. Not strictly required here (the file has only one record
/// type) but future-proof if we ever add other rollup shapes.
pub const RollupRow = struct {
    kind: []const u8,
    as_of_date: Date,
    net_worth: f64,
    liquid: f64,
    illiquid: f64,
};

/// Produce a rollup-row slice from a TimelineSeries. Pure function —
/// caller owns the result, ready to hand to `srf.fmt`.
pub fn buildRollupRecords(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
) ![]RollupRow {
    var out = try allocator.alloc(RollupRow, points.len);
    for (points, 0..) |p, i| {
        out[i] = .{
            .kind = "rollup",
            .as_of_date = p.as_of_date,
            .net_worth = p.net_worth,
            .liquid = p.liquid,
            .illiquid = p.illiquid,
        };
    }
    return out;
}

// ── Snap-backward for snapshot points ────────────────────────

fn pointDateOf(p: TimelinePoint) Date {
    return p.as_of_date;
}

/// Return the latest point on or before `target`. Null if `points` is
/// empty or every entry sits strictly after `target`.
///
/// Delegates to the shared `valuation.indexAtOrBefore` kernel — same
/// snap-backward behavior used by candle pricing, so holiday/weekend
/// semantics are identical across the app. No slack cap: snapshot
/// history is dense enough by construction (one entry per trading day)
/// that caps would only hide real gaps.
pub fn pointAtOrBefore(points: []const TimelinePoint, target: Date) ?*const TimelinePoint {
    const idx = valuation.indexAtOrBefore(TimelinePoint, points, target, pointDateOf) orelse return null;
    return &points[idx];
}

// ── Rolling-windows block ────────────────────────────────────

/// One row in the rolling-windows block. `anchor_date` / `start_value` /
/// `delta_*` are null when there isn't enough history to honor the
/// window (e.g. asking for 10-year on a 2-week-old portfolio).
///
/// `end_value` is always populated — it's the latest point in the
/// series, which must exist for the block to render at all.
pub const WindowStat = struct {
    /// The period this row represents. Null for the synthetic "All-time"
    /// row (anchored to the first snapshot rather than to `today - N`).
    period: ?HistoricalPeriod,
    /// Human-facing label ("1 day", "YTD", "All-time").
    label: []const u8,
    /// Short label used when horizontal space is tight ("1D", "YTD").
    short_label: []const u8,
    /// The snapshot date we anchored to. Null when no snapshot exists at
    /// or before the target date — i.e. not enough history.
    anchor_date: ?Date,
    /// The anchor snapshot's metric value. Null when anchor is missing.
    start_value: ?f64,
    /// Always populated — the latest snapshot's metric value.
    end_value: f64,
    /// `end_value - start_value`. Null when start is missing.
    delta_abs: ?f64,
    /// `(end_value - start_value) / start_value`. Null when start is
    /// missing OR when start is exactly zero (division by zero).
    delta_pct: ?f64,
    /// CAGR — annualized growth rate over the window:
    /// `(end_value / start_value)^(1/years) - 1`.
    /// Null when `start_value` is missing or zero, when
    /// `years <= 0` (degenerate window), or when end/start ratio
    /// is non-positive (which would require equity to go negative).
    /// Years are computed as `(today.days - anchor.days) / 365.25`,
    /// the standard CAGR convention.
    annualized_pct: ?f64,
};

/// Rolling-windows block for a single metric. Owns the `rows` slice.
/// Order: 8 relative-to-today periods (from HistoricalPeriod.timeline_windows),
/// then a final synthetic "All-time" row anchored to the first snapshot.
pub const WindowSet = struct {
    rows: []WindowStat,
    allocator: std.mem.Allocator,

    pub fn deinit(self: WindowSet) void {
        self.allocator.free(self.rows);
    }
};

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

/// Build the rolling-windows block for one metric. `today` is the
/// reference "now" — almost always the last snapshot's as_of_date, but
/// taken as a parameter so tests can pin deterministic scenarios.
///
/// Returns an empty set when `points` is empty.
pub fn computeWindowSet(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
    metric: Metric,
    as_of: Date,
) !WindowSet {
    if (points.len == 0) {
        return .{ .rows = &.{}, .allocator = allocator };
    }

    const windows = HistoricalPeriod.timeline_windows;
    var rows = try allocator.alloc(WindowStat, windows.len + 1);
    errdefer allocator.free(rows);

    const end_point = points[points.len - 1];
    const end_value = extractValue(end_point, metric);

    for (windows, 0..) |period, i| {
        const target = period.targetDate(as_of);
        const anchor_opt = pointAtOrBefore(points, target);

        rows[i] = if (anchor_opt) |a| blk_outer: {
            const sv = extractValue(a.*, metric);
            const dpct: ?f64 = if (sv == 0) null else (end_value - sv) / sv;
            const apct: ?f64 = annualizedFromPct(dpct, a.as_of_date, as_of);
            break :blk_outer .{
                .period = period,
                .label = period.longLabel(),
                .short_label = period.label(),
                .anchor_date = a.as_of_date,
                .start_value = sv,
                .end_value = end_value,
                .delta_abs = end_value - sv,
                .delta_pct = dpct,
                .annualized_pct = apct,
            };
        } else .{
            .period = period,
            .label = period.longLabel(),
            .short_label = period.label(),
            .anchor_date = null,
            .start_value = null,
            .end_value = end_value,
            .delta_abs = null,
            .delta_pct = null,
            .annualized_pct = null,
        };
    }

    // All-time = vs. first snapshot in series. Not a HistoricalPeriod
    // member because it isn't relative to `today` (see valuation.zig
    // doc block).
    const first = points[0];
    const first_value = extractValue(first, metric);
    const all_pct: ?f64 = if (first_value == 0) null else (end_value - first_value) / first_value;
    rows[windows.len] = .{
        .period = null,
        .label = "All-time",
        .short_label = "All",
        .anchor_date = first.as_of_date,
        .start_value = first_value,
        .end_value = end_value,
        .delta_abs = end_value - first_value,
        .delta_pct = all_pct,
        .annualized_pct = annualizedFromPct(all_pct, first.as_of_date, as_of),
    };

    return .{ .rows = rows, .allocator = allocator };
}

/// Compute the annualized return (CAGR) from a cumulative pct
/// over a window. Years are derived from raw day count divided
/// by 365.25 (standard CAGR convention).
///
/// `as_of` is the reference end-date for the window — typically
/// the chart's "now" but the parameter accepts any caller-chosen
/// date (per AGENTS.md, `as_of` not `today` for arbitrary
/// caller-supplied reference dates).
///
/// Returns null when:
///   - `delta_pct` is null (no anchor),
///   - `years <= 0` (degenerate / future-dated window),
///   - `1 + delta_pct <= 0` (would require equity to go negative
///     to losses exceeding 100% — impossible from positive start
///     equity, but defensive against bad input).
fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 {
    const dpct = delta_pct orelse return null;
    const day_diff: f64 = @floatFromInt(as_of.days - anchor.days);
    if (day_diff <= 0) return null;
    const years = day_diff / 365.25;
    const ratio = 1.0 + dpct;
    if (ratio <= 0) return null;
    return std.math.pow(f64, ratio, 1.0 / years) - 1.0;
}

// ── Per-row day-over-day deltas ──────────────────────────────

/// One row in the "Recent snapshots" table after per-row deltas have
/// been computed. The delta is *relative to the previous row in the
/// same resolution* — i.e. when the table is aggregated to weekly,
/// `d_*` fields hold week-over-week change.
///
/// First row has all `d_*` fields null (no prior row to compare against).
pub const RowDelta = struct {
    date: Date,
    liquid: f64,
    illiquid: f64,
    net_worth: f64,
    d_liquid: ?f64,
    d_illiquid: ?f64,
    d_net_worth: ?f64,
};

/// Compute per-row deltas against the previous row. Returns a
/// newly-allocated slice the caller owns. Empty input -> empty output.
pub fn computeRowDeltas(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
) ![]RowDelta {
    var out = try allocator.alloc(RowDelta, points.len);
    for (points, 0..) |p, i| {
        out[i] = .{
            .date = p.as_of_date,
            .liquid = p.liquid,
            .illiquid = p.illiquid,
            .net_worth = p.net_worth,
            .d_liquid = if (i == 0) null else p.liquid - points[i - 1].liquid,
            .d_illiquid = if (i == 0) null else p.illiquid - points[i - 1].illiquid,
            .d_net_worth = if (i == 0) null else p.net_worth - points[i - 1].net_worth,
        };
    }
    return out;
}

// ── Resolution (daily / weekly / monthly) ────────────────────

pub const Resolution = enum {
    daily,
    weekly,
    monthly,
    /// Multi-tier cascade — daily, weekly, monthly, quarterly,
    /// yearly. Produced by `aggregateCascading`, not by the
    /// `aggregatePoints` flat-aggregation function.
    cascading,

    pub fn label(self: Resolution) []const u8 {
        return switch (self) {
            .daily => "daily",
            .weekly => "weekly",
            .monthly => "monthly",
            .cascading => "cascading",
        };
    }
};

/// Pick a default resolution based on series span.
///   span ≤ 90d  → daily
///   span ≤ 730d → weekly
///   else        → monthly
///
/// Empty / single-point series always return `daily` (there's nothing
/// to aggregate).
pub fn selectResolution(points: []const TimelinePoint) Resolution {
    if (points.len < 2) return .daily;
    const first = points[0].as_of_date;
    const last = points[points.len - 1].as_of_date;
    const span_days = last.days - first.days;
    if (span_days <= 90) return .daily;
    if (span_days <= 730) return .weekly;
    return .monthly;
}

/// Aggregate `points` to the requested resolution. Returns a
/// newly-allocated slice the caller owns.
///
/// `daily`   → returns a copy of the input.
/// `weekly`  → rolling 7-day buckets walking *backward from latest*, one
///             representative point per bucket (the latest in the bucket,
///             not the oldest — matches brokerage weekly-bar convention).
///             The returned slice is sorted ascending by date.
/// `monthly` → groups by calendar (year, month); picks the latest snapshot
///             in each month. Sorted ascending by date.
///
/// Empty input returns an empty owned slice.
pub fn aggregatePoints(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
    resolution: Resolution,
) ![]TimelinePoint {
    if (points.len == 0) return allocator.alloc(TimelinePoint, 0);

    switch (resolution) {
        .daily => {
            const out = try allocator.alloc(TimelinePoint, points.len);
            @memcpy(out, points);
            return out;
        },
        .weekly => return aggregateWeeklyRolling(allocator, points),
        .monthly => return aggregateMonthly(allocator, points),
        // Cascading is not a flat resolution; callers should use
        // `aggregateCascading` directly. Emit an empty slice to
        // keep this entry-point's invariants simple.
        .cascading => return allocator.alloc(TimelinePoint, 0),
    }
}

/// Walk backward in 7-day strides from the latest point. The latest
/// point always seeds bucket 0; subsequent buckets cover
/// `(latest - 7i - 6) … (latest - 7i)` inclusive. Each bucket emits
/// its latest-date member. Output is sorted ascending.
fn aggregateWeeklyRolling(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
) ![]TimelinePoint {
    var picked: std.ArrayList(TimelinePoint) = .empty;
    errdefer picked.deinit(allocator);

    const last_date = points[points.len - 1].as_of_date;

    // Bucket index i covers dates in [last - 7i - 6, last - 7i].
    // We scan points newest-first. For each point, compute its bucket
    // relative to `last`; keep the first (i.e. newest) one we see per
    // bucket.
    var current_bucket: i32 = -1;
    var i: usize = points.len;
    while (i > 0) {
        i -= 1;
        const p = points[i];
        const age_days = last_date.days - p.as_of_date.days;
        if (age_days < 0) continue; // shouldn't happen (series is sorted)
        const bucket: i32 = @divFloor(age_days, 7);
        if (bucket != current_bucket) {
            try picked.append(allocator, p);
            current_bucket = bucket;
        }
    }

    // picked is newest-first; reverse to ascending.
    std.mem.reverse(TimelinePoint, picked.items);
    return picked.toOwnedSlice(allocator);
}

/// Group by (year, month); emit the latest-date member of each group.
fn aggregateMonthly(
    allocator: std.mem.Allocator,
    points: []const TimelinePoint,
) ![]TimelinePoint {
    var picked: std.ArrayList(TimelinePoint) = .empty;
    errdefer picked.deinit(allocator);

    // Scan ascending (points are already sorted). For each (year, month)
    // key, keep updating the "representative" point until the key
    // changes; then commit the previous one.
    var cur_year: i16 = 0;
    var cur_month: u8 = 0;
    var cur_point: ?TimelinePoint = null;

    for (points) |p| {
        const y = p.as_of_date.year();
        const m = p.as_of_date.month();
        if (cur_point == null) {
            cur_year = y;
            cur_month = m;
            cur_point = p;
            continue;
        }
        if (y == cur_year and m == cur_month) {
            cur_point = p; // same month; keep the latest
        } else {
            try picked.append(allocator, cur_point.?);
            cur_year = y;
            cur_month = m;
            cur_point = p;
        }
    }
    if (cur_point) |p| try picked.append(allocator, p);

    return picked.toOwnedSlice(allocator);
}

// ── Cascading (multi-tier) aggregation ───────────────────────

/// One tier in the cascading view of recent history. Tag names
/// double as display labels — use `@tagName(t)` directly when
/// rendering.
pub const Tier = enum {
    daily,
    weekly,
    monthly,
    quarterly,
    yearly,
};

/// One bucket in the cascading view. `representative_date` is
/// the date of the latest data point inside the bucket — the row
/// "represents" that point's values. `bucket_start` / `bucket_end`
/// describe the bucket's calendar range.
///
/// `series_slice` is a non-owning view into the `series` passed
/// to `aggregateCascading` — the points that fell inside this
/// bucket's date range. Drilldown via `childBuckets` walks the
/// parent's slice directly. Empty buckets get an empty slice.
///
/// Lifetime: the slice borrows from the caller-owned series,
/// which must outlive the `TieredSeries` and any drill-down
/// calls. In practice the series lives in `LoadedTimeline` (TUI)
/// or a local arena (CLI), both of which dominate any
/// `TieredSeries` produced from them.
pub const TierBucket = struct {
    tier: Tier,
    bucket_start: Date,
    bucket_end: Date,
    representative_date: Date,
    liquid: f64,
    illiquid: f64,
    net_worth: f64,
    /// True when every data point in the bucket came from
    /// imported_values (no native snapshot landed inside it).
    /// Renderers use this to gate `—` for illiquid/net_worth
    /// cells and to skip plotting illiquid/net_worth in graphs.
    imported_only: bool,
    /// The contiguous slice of the source series that fell in
    /// this bucket's date range. Empty for synthetic buckets
    /// that span a date range with no data points.
    series_slice: []const TimelinePoint,
};

/// Owned cascading-view result. Buckets are concatenated across
/// tiers in newest-first order: daily buckets, then weekly, then
/// monthly, quarterly, yearly. Tier transitions are inferred by
/// walking the slice and watching `bucket.tier` change.
pub const TieredSeries = struct {
    buckets: []TierBucket,
    allocator: std.mem.Allocator,

    pub fn deinit(self: TieredSeries) void {
        self.allocator.free(self.buckets);
    }
};

/// Per-bucket delta ("change since the older neighbor").
///
/// Buckets in `TieredSeries` are stored newest-first. The
/// "older neighbor" of bucket `i` is therefore `buckets[i+1]`.
/// `delta_*` on the OLDEST bucket (last in the slice) is null
/// — there's no older neighbor to compare against.
///
/// `delta_illiquid` and `delta_net_worth` are also null when
/// either neighbor is `imported_only` (imported_values doesn't
/// carry illiquid, so a Δ across an imported boundary is
/// meaningless).
pub const BucketDelta = struct {
    delta_liquid: ?f64,
    delta_illiquid: ?f64,
    delta_net_worth: ?f64,
};

/// Compute per-bucket deltas for a `TieredSeries`. Returns a
/// slice the same length as `series.buckets`, parallel-indexed.
///
/// `delta_*` semantics: Δ on bucket `i` is `buckets[i].value -
/// buckets[i+1].value` (current minus older neighbor). Newest-
/// first iteration: positive Δ on a row = "the portfolio was
/// higher on this date than at the older neighbor's date" =
/// "we gained going forward in time." Matches the existing
/// `computeRowDeltas` convention for the flat-resolution table.
pub fn computeBucketDeltas(
    allocator: std.mem.Allocator,
    buckets: []const TierBucket,
) ![]BucketDelta {
    const out = try allocator.alloc(BucketDelta, buckets.len);
    for (buckets, 0..) |b, i| {
        const older_idx = i + 1;
        if (older_idx >= buckets.len) {
            out[i] = .{ .delta_liquid = null, .delta_illiquid = null, .delta_net_worth = null };
            continue;
        }
        const older = buckets[older_idx];
        const dl: ?f64 = b.liquid - older.liquid;
        // Illiquid / net_worth Δ is undefined when either
        // neighbor lacks the data (imported_only buckets).
        const cross_imported = b.imported_only or older.imported_only;
        const di: ?f64 = if (cross_imported) null else b.illiquid - older.illiquid;
        const dn: ?f64 = if (cross_imported) null else b.net_worth - older.net_worth;
        out[i] = .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn };
    }
    return out;
}

/// Build the cascading view from a date-ascending series.
/// `as_of` is the reference date — the daily tier covers
/// `[as_of.subDays(13), as_of]`. Pass `series[series.len-1].as_of_date`
/// for the typical case; pin a deterministic value in tests.
/// Per AGENTS.md: named `as_of` (not `today`) because callers
/// can legitimately pass any date — for back-dated views, the
/// reference is whatever the user asked for, not the calendar
/// day.
///
/// The tier coverage rules implement the user-facing intent:
/// always show at least one bucket of each granularity that has
/// any data, and never skip a tier when transitioning from finer
/// to coarser.
///
/// **Daily:** every point in `[as_of.subDays(13), as_of]`.
///
/// **Weekly:** weeks (Monday→Sunday) ending strictly before the
/// daily tier's earliest covered date, going back 4 weeks. Buckets
/// with zero data are skipped.
///
/// **Monthly:** calendar months ending before the weekly tier's
/// earliest covered date, walking back to Jan 1 of `as_of.year()`.
/// If that's fewer than 3 months, extend backward into the prior
/// year so at least 3 months are emitted (when data exists).
///
/// **Quarterly:** the most recent prior calendar year (typically
/// `as_of.year() - 1`). Up to 4 quarters newest-first.
///
/// **Yearly:** every calendar year before the quarterly tier's
/// year that has any data, newest-first.
pub fn aggregateCascading(
    allocator: std.mem.Allocator,
    series: []const TimelinePoint,
    as_of: Date,
) !TieredSeries {
    if (series.len == 0) {
        return .{ .buckets = &.{}, .allocator = allocator };
    }

    // Compute the tier boundaries.
    //
    // Daily: every point in `[daily_start, as_of]`. Each becomes
    //   its own per-point bucket (no aggregation).
    // Weekly: 4 buckets ending at `weekly_end_initial`, walking
    //   back 7 days at a time.
    // Monthly: calendar months of `as_of.year()`, from Jan to
    //   `monthly_boundary.month()`. Skipped when the boundary
    //   falls before Jan of the as-of year.
    // Quarterly: 4 calendar quarters of `as_of.year() - 1`.
    // Yearly: every calendar year before `quarterly_year`,
    //   from `series[0].as_of_date.year()` up to `quarterly_year - 1`.
    //
    // We pre-build a flat frame of non-daily buckets in
    // chronological (oldest-first) order. Then we make a single
    // forward pass through `series`, advancing a cursor in the
    // frame as point dates cross bucket boundaries. Each bucket
    // remembers its latest in-range point.
    const daily_start = as_of.addDays(-13);
    const weekly_end_initial = daily_start.addDays(-1);
    const monthly_boundary = weekly_end_initial.addDays(-28);
    const as_of_year = as_of.year();
    const quarterly_year: i16 = as_of_year - 1;

    // ── Build the non-daily frame, oldest-first ──────────────
    //
    // Order: yearly (earliest..latest) → quarterly (Q1..Q4) →
    // monthly (Jan..boundary month) → weekly (oldest..newest of
    // the 4 weeks before the daily tier).
    //
    // This keeps frame entries strictly date-ascending. As we
    // walk the series (also date-ascending), the bucket cursor
    // only ever moves forward.
    var frame: std.ArrayList(BucketFrame) = .empty;
    defer frame.deinit(allocator);

    // Yearly buckets.
    {
        const first_y = series[0].as_of_date.year();
        if (first_y < quarterly_year) {
            var y = first_y;
            while (y < quarterly_year) : (y += 1) {
                try frame.append(allocator, .{
                    .tier = .yearly,
                    .bucket_start = Date.fromYmd(y, 1, 1),
                    .bucket_end = Date.fromYmd(y, 12, 31),
                    .latest = null,
                    .any_snapshot = false,
                    .series_start = 0,
                    .series_end = 0,
                });
            }
        }
    }

    // Quarterly buckets — Q1..Q4 of quarterly_year.
    {
        var q: u8 = 1;
        while (q <= 4) : (q += 1) {
            const start_month: u8 = (q - 1) * 3 + 1;
            const end_month: u8 = q * 3;
            try frame.append(allocator, .{
                .tier = .quarterly,
                .bucket_start = Date.fromYmd(quarterly_year, start_month, 1),
                .bucket_end = Date.lastDayOfMonth(quarterly_year, end_month),
                .latest = null,
                .any_snapshot = false,
                .series_start = 0,
                .series_end = 0,
            });
        }
    }

    // Monthly buckets — Jan..monthly_boundary.month() of
    // as_of_year. Skip entirely if the boundary precedes the
    // year (e.g. very early in January).
    if (monthly_boundary.year() == as_of_year) {
        const max_month = monthly_boundary.month();
        var m: u8 = 1;
        while (m <= max_month) : (m += 1) {
            try frame.append(allocator, .{
                .tier = .monthly,
                .bucket_start = Date.fromYmd(as_of_year, m, 1),
                .bucket_end = Date.lastDayOfMonth(as_of_year, m),
                .latest = null,
                .any_snapshot = false,
                .series_start = 0,
                .series_end = 0,
            });
        }
    }

    // Weekly buckets — 4 weeks ending at weekly_end_initial.
    // Build oldest-first to maintain frame ascending order.
    {
        var w: i32 = 3;
        while (w >= 0) : (w -= 1) {
            const end = weekly_end_initial.addDays(-7 * w);
            const start = end.addDays(-6);
            try frame.append(allocator, .{
                .tier = .weekly,
                .bucket_start = start,
                .bucket_end = end,
                .latest = null,
                .any_snapshot = false,
                .series_start = 0,
                .series_end = 0,
            });
            if (w == 0) break;
        }
    }

    // ── Single forward pass through the series ───────────────
    //
    // Maintain `cursor` into `frame`. For each point:
    //   - If point.date > as_of: skip (future-dated; rare).
    //   - If point.date >= daily_start: daily tier; remember
    //     for later (we emit dailies newest-first at the end).
    //   - Else advance cursor while point.date > frame[cursor].bucket_end.
    //     If point.date >= frame[cursor].bucket_start, update
    //     that bucket's `latest`, `any_snapshot`, and series-
    //     index range. Else the point falls in a gap between
    //     frame entries (rare; e.g. older than the earliest
    //     yearly bucket) — drop it.
    //
    // Each frame bucket's `series_start` / `series_end` end up
    // forming the half-open slice of `series` that fell in its
    // calendar range. `series_start` is set on the first hit;
    // `series_end` is bumped to `idx + 1` on every hit. Empty
    // buckets keep `series_start == series_end == 0`, which is
    // a benign empty slice.
    const DailyPoint = struct { p: TimelinePoint, idx: usize };
    var daily_points: std.ArrayList(DailyPoint) = .empty;
    defer daily_points.deinit(allocator);

    var cursor: usize = 0;
    for (series, 0..) |p, idx| {
        if (p.as_of_date.days > as_of.days) continue;
        if (p.as_of_date.days >= daily_start.days) {
            try daily_points.append(allocator, .{ .p = p, .idx = idx });
            continue;
        }
        // Advance the cursor until the current frame entry could
        // contain `p`.
        while (cursor < frame.items.len and p.as_of_date.days > frame.items[cursor].bucket_end.days) {
            cursor += 1;
        }
        if (cursor >= frame.items.len) break; // no more buckets ahead
        const fb = &frame.items[cursor];
        if (p.as_of_date.days < fb.bucket_start.days) {
            // Point is in a gap between buckets (e.g. point falls
            // in the year before the earliest yearly bucket — but
            // by construction yearly starts at series[0].year, so
            // this only happens for points that didn't fit any
            // tier's date range). Skip.
            continue;
        }
        if (fb.latest == null) fb.series_start = idx;
        fb.series_end = idx + 1;
        fb.latest = p;
        if (p.source == .snapshot) fb.any_snapshot = true;
    }

    // ── Assemble output, newest-first ────────────────────────
    var out: std.ArrayList(TierBucket) = .empty;
    errdefer out.deinit(allocator);

    // Daily points: collected oldest-first; emit newest-first.
    var di: usize = daily_points.items.len;
    while (di > 0) {
        di -= 1;
        const dp = daily_points.items[di];
        try out.append(allocator, .{
            .tier = .daily,
            .bucket_start = dp.p.as_of_date,
            .bucket_end = dp.p.as_of_date,
            .representative_date = dp.p.as_of_date,
            .liquid = dp.p.liquid,
            .illiquid = dp.p.illiquid,
            .net_worth = dp.p.net_worth,
            .imported_only = dp.p.source == .imported,
            .series_slice = series[dp.idx .. dp.idx + 1],
        });
    }

    // Non-daily buckets: frame is oldest-first; emit newest-first.
    var fi: usize = frame.items.len;
    while (fi > 0) {
        fi -= 1;
        const fb = frame.items[fi];
        const p = fb.latest orelse continue;
        try out.append(allocator, .{
            .tier = fb.tier,
            .bucket_start = fb.bucket_start,
            .bucket_end = fb.bucket_end,
            .representative_date = p.as_of_date,
            .liquid = p.liquid,
            .illiquid = p.illiquid,
            .net_worth = p.net_worth,
            .imported_only = !fb.any_snapshot,
            .series_slice = series[fb.series_start..fb.series_end],
        });
    }

    return .{ .buckets = try out.toOwnedSlice(allocator), .allocator = allocator };
}

/// Mutable bucket-accumulator state used during the single
/// forward pass in `aggregateCascading` and `childBuckets`.
/// `latest` holds the most recent in-range point seen so far;
/// `any_snapshot` flips on the first snapshot-sourced point
/// that lands in this bucket. `series_start` / `series_end`
/// track the half-open index range into whatever series was
/// being walked at the time — used to construct the bucket's
/// final `series_slice` at emit time.
const BucketFrame = struct {
    tier: Tier,
    bucket_start: Date,
    bucket_end: Date,
    latest: ?TimelinePoint,
    any_snapshot: bool,
    series_start: usize,
    series_end: usize,
};

// ── Hierarchical drill-down ──────────────────────────────────
//
// The cascading view's recent-snapshots table renders each
// bucket as its own row; clicking a bucket reveals its child
// buckets at the next-finer tier (a yearly bucket reveals
// quarterlies, a quarterly reveals monthlies, etc.).

/// Return the next-finer tier for `tier`, or null if there's no
/// finer tier (daily is the leaf).
pub fn finerTier(tier: Tier) ?Tier {
    return switch (tier) {
        .yearly => .quarterly,
        .quarterly => .monthly,
        .monthly => .weekly,
        .weekly => .daily,
        .daily => null,
    };
}

/// Render a tier-aware label for a bucket. Caller passes the
/// bucket's tier and `bucket_start` date; the function emits
/// the appropriate display label into `buf` and returns a
/// slice of it. Shared by CLI and TUI cascading renderers.
///
///   - daily: `YYYY-MM-DD`
///   - weekly: `W of YYYY-MM-DD` (start-of-week)
///   - monthly: `MMM YYYY`
///   - quarterly: `QN YYYY`
///   - yearly: `YYYY`
///
/// `buf` should be at least 16 bytes for the longest label
/// ("W of YYYY-MM-DD" = 15). Returns `"?"` if `bufPrint` fails.
pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 {
    return switch (tier) {
        .daily => std.fmt.bufPrint(buf, "{f}", .{bucket_start}) catch "?",
        .weekly => std.fmt.bufPrint(buf, "W of {f}", .{bucket_start}) catch "?",
        .monthly => std.fmt.bufPrint(buf, "{s} {d}", .{ Date.monthShort(bucket_start.month()), bucket_start.year() }) catch "?",
        .quarterly => std.fmt.bufPrint(buf, "Q{d} {d}", .{ ((bucket_start.month() - 1) / 3) + 1, bucket_start.year() }) catch "?",
        .yearly => std.fmt.bufPrint(buf, "{d}", .{bucket_start.year()}) catch "?",
    };
}

/// Build the child buckets contained inside `parent`. Returns
/// an owned slice, newest-first. Empty slice when `parent.tier
/// == .daily` (no children) or no data falls inside `parent`'s
/// date range.
///
/// The granularity of children is determined by `finerTier`:
///   yearly  → quarterly children (Q4..Q1 of that year)
///   quarterly → monthly children (last month..first month)
///   monthly → weekly children (calendar-aligned weeks ending
///     within the month, newest-first)
///   weekly → daily children (every data point in the 7-day range)
pub fn childBuckets(
    allocator: std.mem.Allocator,
    parent: TierBucket,
) ![]TierBucket {
    var out: std.ArrayList(TierBucket) = .empty;
    errdefer out.deinit(allocator);

    // Daily parents have no children.
    if (parent.tier == .daily) return out.toOwnedSlice(allocator);

    // The parent's contents are already pinned in
    // `parent.series_slice` — recorded by `aggregateCascading`
    // when the bucket was emitted. No re-scan required.
    const sub = parent.series_slice;

    // Weekly parent → daily children. Each in-range point becomes
    // its own bucket. Walk `sub` newest-first directly.
    if (parent.tier == .weekly) {
        var i: usize = sub.len;
        while (i > 0) {
            i -= 1;
            const p = sub[i];
            try out.append(allocator, .{
                .tier = .daily,
                .bucket_start = p.as_of_date,
                .bucket_end = p.as_of_date,
                .representative_date = p.as_of_date,
                .liquid = p.liquid,
                .illiquid = p.illiquid,
                .net_worth = p.net_worth,
                .imported_only = p.source == .imported,
                .series_slice = sub[i .. i + 1],
            });
        }
        return out.toOwnedSlice(allocator);
    }

    // Monthly / quarterly / yearly parents: build a frame of
    // children in chronological (oldest-first) order, walk the
    // parent's `sub` slice once, bin each point into the current
    // child via a forward-only cursor. Emit non-empty buckets
    // newest-first at the end.
    var frame: std.ArrayList(BucketFrame) = .empty;
    defer frame.deinit(allocator);

    const child_tier = finerTier(parent.tier).?;

    switch (parent.tier) {
        .monthly => {
            // Weekly children: 7-day buckets ending at the last
            // day of the parent month, walking back. Build them
            // in oldest-first order so the frame stays ascending.
            const month_start = parent.bucket_start;
            const month_end = parent.bucket_end;

            // Generate week boundaries newest-first (anchored at
            // month_end) into a temporary, then reverse into the
            // frame.
            var weeks_newest_first: std.ArrayList(struct { start: Date, end: Date }) = .empty;
            defer weeks_newest_first.deinit(allocator);
            var bucket_end_w = month_end;
            while (bucket_end_w.days >= month_start.days) {
                const bucket_start_raw = bucket_end_w.addDays(-6);
                const bucket_start_w = if (bucket_start_raw.days < month_start.days) month_start else bucket_start_raw;
                try weeks_newest_first.append(allocator, .{ .start = bucket_start_w, .end = bucket_end_w });
                if (bucket_start_w.days <= month_start.days) break;
                bucket_end_w = bucket_start_w.addDays(-1);
            }
            // Reverse into frame (oldest-first).
            var wi: usize = weeks_newest_first.items.len;
            while (wi > 0) {
                wi -= 1;
                const w = weeks_newest_first.items[wi];
                try frame.append(allocator, .{
                    .tier = .weekly,
                    .bucket_start = w.start,
                    .bucket_end = w.end,
                    .latest = null,
                    .any_snapshot = false,
                    .series_start = 0,
                    .series_end = 0,
                });
            }
        },
        .quarterly => {
            // Monthly children: 3 calendar months in the parent
            // quarter, oldest-first.
            const start_month = parent.bucket_start.month();
            const year = parent.bucket_start.year();
            var off: u8 = 0;
            while (off < 3) : (off += 1) {
                const month: u8 = start_month + off;
                try frame.append(allocator, .{
                    .tier = .monthly,
                    .bucket_start = Date.fromYmd(year, month, 1),
                    .bucket_end = Date.lastDayOfMonth(year, month),
                    .latest = null,
                    .any_snapshot = false,
                    .series_start = 0,
                    .series_end = 0,
                });
            }
        },
        .yearly => {
            // Quarterly children: Q1..Q4 of the parent year,
            // oldest-first.
            const year = parent.bucket_start.year();
            var q: u8 = 1;
            while (q <= 4) : (q += 1) {
                const start_month: u8 = (q - 1) * 3 + 1;
                const end_month: u8 = q * 3;
                try frame.append(allocator, .{
                    .tier = .quarterly,
                    .bucket_start = Date.fromYmd(year, start_month, 1),
                    .bucket_end = Date.lastDayOfMonth(year, end_month),
                    .latest = null,
                    .any_snapshot = false,
                    .series_start = 0,
                    .series_end = 0,
                });
            }
        },
        // Already handled above.
        .daily, .weekly => unreachable,
    }

    // Single forward pass over the parent's slice. No "skip
    // until enter / break when leave" — every point in `sub`
    // is by construction inside the parent's date range.
    //
    // Frame indices (`series_start`/`series_end`) are recorded
    // *relative to `sub`* during the pass, then sliced into
    // `sub` at emit time so each child's `series_slice` is a
    // sub-view of the parent's slice.
    var cursor: usize = 0;
    for (sub, 0..) |p, sub_idx| {
        // Advance the child-cursor until the current frame entry
        // contains this point. Frame entries are non-overlapping
        // and oldest-first within the parent range.
        while (cursor < frame.items.len and p.as_of_date.days > frame.items[cursor].bucket_end.days) {
            cursor += 1;
        }
        if (cursor >= frame.items.len) break;
        const fb = &frame.items[cursor];
        if (p.as_of_date.days < fb.bucket_start.days) continue; // gap (shouldn't happen for these tiers)
        if (fb.latest == null) fb.series_start = sub_idx;
        fb.series_end = sub_idx + 1;
        fb.latest = p;
        if (p.source == .snapshot) fb.any_snapshot = true;
    }

    // Emit non-empty children newest-first.
    var fi: usize = frame.items.len;
    while (fi > 0) {
        fi -= 1;
        const fb = frame.items[fi];
        const p = fb.latest orelse continue;
        try out.append(allocator, .{
            .tier = child_tier,
            .bucket_start = fb.bucket_start,
            .bucket_end = fb.bucket_end,
            .representative_date = p.as_of_date,
            .liquid = p.liquid,
            .illiquid = p.illiquid,
            .net_worth = p.net_worth,
            .imported_only = !fb.any_snapshot,
            .series_slice = sub[fb.series_start..fb.series_end],
        });
    }

    return out.toOwnedSlice(allocator);
}

// ── Tests ────────────────────────────────────────────────────
//
// Pure compute — every function here can be exercised with fixture
// structs. No IO, no writer, no colors.

const testing = std.testing;

/// Populate `buf` with the three total rows and return a minimal
/// Snapshot fixture for tests. Each test supplies its own `buf` (three
/// `TotalRow`s on the stack) so storage doesn't alias across calls.
fn fixtureSnapshot(
    buf: *[3]snapshot.TotalRow,
    y: i16,
    m: u8,
    d: u8,
    net_worth: f64,
    liquid: f64,
    illiquid: f64,
) snapshot.Snapshot {
    buf[0] = .{ .kind = "total", .scope = "net_worth", .value = net_worth };
    buf[1] = .{ .kind = "total", .scope = "liquid", .value = liquid };
    buf[2] = .{ .kind = "total", .scope = "illiquid", .value = illiquid };

    return .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(y, m, d),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = buf,
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &.{},
    };
}

test "snapshotToPoint: extracts the three totals" {
    var buf: [3]snapshot.TotalRow = undefined;
    const snap = fixtureSnapshot(&buf, 2026, 4, 17, 1000, 800, 200);
    const p = try snapshotToPoint(testing.allocator, snap);
    defer p.deinit(testing.allocator);

    try testing.expect(p.as_of_date.eql(Date.fromYmd(2026, 4, 17)));
    try testing.expectEqual(@as(f64, 1000), p.net_worth);
    try testing.expectEqual(@as(f64, 800), p.liquid);
    try testing.expectEqual(@as(f64, 200), p.illiquid);
    try testing.expectEqual(@as(usize, 0), p.accounts.len);
    try testing.expectEqual(@as(usize, 0), p.tax_types.len);
}

test "snapshotToPoint: missing totals default to zero" {
    // Snapshot with empty totals slice — nothing at all to extract.
    const snap: snapshot.Snapshot = .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2026, 4, 17),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = &.{},
        .tax_types = &.{},
        .accounts = &.{},
        .lots = &.{},
    };
    const p = try snapshotToPoint(testing.allocator, snap);
    defer p.deinit(testing.allocator);
    try testing.expectEqual(@as(f64, 0), p.net_worth);
    try testing.expectEqual(@as(f64, 0), p.liquid);
    try testing.expectEqual(@as(f64, 0), p.illiquid);
}

test "snapshotToPoint: propagates accounts and tax_types" {
    const accts = [_]snapshot.AccountRow{
        .{ .kind = "account", .name = "Sample Roth", .value = 1000 },
        .{ .kind = "account", .name = "Riley IRA", .value = 500 },
    };
    const taxes = [_]snapshot.TaxTypeRow{
        .{ .kind = "tax_type", .label = "Taxable", .value = 800 },
    };
    const totals = [_]snapshot.TotalRow{
        .{ .kind = "total", .scope = "net_worth", .value = 1500 },
    };
    const snap: snapshot.Snapshot = .{
        .meta = .{
            .kind = "meta",
            .snapshot_version = 1,
            .as_of_date = Date.fromYmd(2026, 4, 17),
            .captured_at = 0,
            .zfin_version = "test",
            .stale_count = 0,
        },
        .totals = @constCast(&totals),
        .tax_types = @constCast(&taxes),
        .accounts = @constCast(&accts),
        .lots = &.{},
    };
    const p = try snapshotToPoint(testing.allocator, snap);
    defer p.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 2), p.accounts.len);
    try testing.expectEqualStrings("Sample Roth", p.accounts[0].name);
    try testing.expectEqual(@as(f64, 1000), p.accounts[0].value);

    try testing.expectEqual(@as(usize, 1), p.tax_types.len);
    try testing.expectEqualStrings("Taxable", p.tax_types[0].name);
}

test "buildSeries: empty input" {
    const series = try buildSeries(testing.allocator, &.{});
    defer series.deinit();
    try testing.expectEqual(@as(usize, 0), series.points.len);
}

test "buildSeries: sorts ascending by date" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 20, 3000, 0, 0),
        fixtureSnapshot(&b2, 2026, 4, 17, 1000, 0, 0),
        fixtureSnapshot(&b3, 2026, 4, 18, 2000, 0, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    try testing.expectEqual(@as(usize, 3), series.points.len);
    try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2026, 4, 17)));
    try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2026, 4, 18)));
    try testing.expect(series.points[2].as_of_date.eql(Date.fromYmd(2026, 4, 20)));
    try testing.expectEqual(@as(f64, 1000), series.points[0].net_worth);
    try testing.expectEqual(@as(f64, 3000), series.points[2].net_worth);
}

test "buildMergedSeries: empty inputs" {
    const series = try buildMergedSeries(testing.allocator, &.{}, &.{});
    defer series.deinit();
    try testing.expectEqual(@as(usize, 0), series.points.len);
}

test "buildMergedSeries: imported only" {
    const imp = [_]ImportedHistoryPoint{
        .{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 },
        .{ .date = Date.fromYmd(2014, 7, 10), .liquid = 1_273_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
    defer series.deinit();
    try testing.expectEqual(@as(usize, 2), series.points.len);
    try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2014, 7, 3)));
    try testing.expectEqual(@as(f64, 1_280_000), series.points[0].liquid);
    try testing.expectEqual(@as(f64, 0), series.points[0].illiquid);
    try testing.expectEqual(@as(f64, 1_280_000), series.points[0].net_worth);
    try testing.expect(series.points[0].source == .imported);
}

test "buildMergedSeries: snapshots only" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 20, 9_000_000, 8_000_000, 1_000_000),
        fixtureSnapshot(&b2, 2026, 4, 21, 9_100_000, 8_100_000, 1_000_000),
    };
    const series = try buildMergedSeries(testing.allocator, &snaps, &.{});
    defer series.deinit();
    try testing.expectEqual(@as(usize, 2), series.points.len);
    try testing.expect(series.points[0].source == .snapshot);
    try testing.expectEqual(@as(f64, 1_000_000), series.points[0].illiquid);
}

test "buildMergedSeries: snapshot wins on overlap" {
    var b1: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2025, 6, 1, 5_000_000, 4_500_000, 500_000),
    };
    const imp = [_]ImportedHistoryPoint{
        // Overlapping date — snapshot wins.
        .{ .date = Date.fromYmd(2025, 6, 1), .liquid = 4_400_000 },
        // Non-overlapping — kept.
        .{ .date = Date.fromYmd(2025, 5, 25), .liquid = 4_300_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
    defer series.deinit();
    try testing.expectEqual(@as(usize, 2), series.points.len);
    // Sorted ascending: 2025-05-25 first, then 2025-06-01.
    try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2025, 5, 25)));
    try testing.expect(series.points[0].source == .imported);
    try testing.expectEqual(@as(f64, 4_300_000), series.points[0].liquid);
    try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2025, 6, 1)));
    try testing.expect(series.points[1].source == .snapshot);
    // Snapshot value, not imported value.
    try testing.expectEqual(@as(f64, 4_500_000), series.points[1].liquid);
    try testing.expectEqual(@as(f64, 500_000), series.points[1].illiquid);
}

test "buildMergedSeries: result is sorted ascending" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 20, 9_000_000, 8_000_000, 1_000_000),
        fixtureSnapshot(&b2, 2014, 8, 1, 1_300_000, 1_300_000, 0),
    };
    const imp = [_]ImportedHistoryPoint{
        .{ .date = Date.fromYmd(2020, 1, 15), .liquid = 3_000_000 },
        .{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &snaps, &imp);
    defer series.deinit();
    try testing.expectEqual(@as(usize, 4), series.points.len);
    try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2014, 7, 3)));
    try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2014, 8, 1)));
    try testing.expect(series.points[2].as_of_date.eql(Date.fromYmd(2020, 1, 15)));
    try testing.expect(series.points[3].as_of_date.eql(Date.fromYmd(2026, 4, 20)));
}

test "filterByDate: inclusive bounds" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    var b4: [3]snapshot.TotalRow = undefined;
    var b5: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
        fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
        fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0),
        fixtureSnapshot(&b4, 2026, 4, 20, 4, 0, 0),
        fixtureSnapshot(&b5, 2026, 4, 21, 5, 0, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const kept = try filterByDate(
        testing.allocator,
        series.points,
        Date.fromYmd(2026, 4, 18),
        Date.fromYmd(2026, 4, 20),
    );
    defer testing.allocator.free(kept);

    try testing.expectEqual(@as(usize, 3), kept.len);
    try testing.expectEqual(@as(f64, 2), kept[0].net_worth);
    try testing.expectEqual(@as(f64, 4), kept[2].net_worth);
}

test "filterByDate: null since leaves left end open" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
        fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
        fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const kept = try filterByDate(testing.allocator, series.points, null, Date.fromYmd(2026, 4, 18));
    defer testing.allocator.free(kept);
    try testing.expectEqual(@as(usize, 2), kept.len);
}

test "filterByDate: null until leaves right end open" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
        fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
        fixtureSnapshot(&b3, 2026, 4, 19, 3, 0, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const kept = try filterByDate(testing.allocator, series.points, Date.fromYmd(2026, 4, 18), null);
    defer testing.allocator.free(kept);
    try testing.expectEqual(@as(usize, 2), kept.len);
}

test "filterByDate: both bounds null returns everything" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
        fixtureSnapshot(&b2, 2026, 4, 18, 2, 0, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();
    const kept = try filterByDate(testing.allocator, series.points, null, null);
    defer testing.allocator.free(kept);
    try testing.expectEqual(@as(usize, 2), kept.len);
}

test "filterByDate: out-of-range bounds return empty" {
    var b1: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1, 0, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();
    const kept = try filterByDate(testing.allocator, series.points, Date.fromYmd(2030, 1, 1), null);
    defer testing.allocator.free(kept);
    try testing.expectEqual(@as(usize, 0), kept.len);
}

test "extractMetric: net_worth / liquid / illiquid" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200),
        fixtureSnapshot(&b2, 2026, 4, 18, 2000, 1500, 500),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    inline for (.{
        .{ Metric.net_worth, [_]f64{ 1000, 2000 } },
        .{ Metric.liquid, [_]f64{ 800, 1500 } },
        .{ Metric.illiquid, [_]f64{ 200, 500 } },
    }) |tc| {
        const out = try extractMetric(testing.allocator, series.points, tc[0]);
        defer testing.allocator.free(out);
        try testing.expectEqual(@as(usize, 2), out.len);
        try testing.expectEqual(tc[1][0], out[0].value);
        try testing.expectEqual(tc[1][1], out[1].value);
    }
}

test "Metric.label: stable strings" {
    try testing.expectEqualStrings("Net Worth", Metric.net_worth.label());
    try testing.expectEqualStrings("Liquid", Metric.liquid.label());
    try testing.expectEqualStrings("Illiquid", Metric.illiquid.label());
}

test "extractNamedSeries accounts: matches + absent days emit 0" {
    // Build three snapshots: day1 has account A; day2 has account B; day3 has both.
    // Extracting "A" should see value on day1, 0 on day2, value on day3.
    var day1_accts = [_]snapshot.AccountRow{.{ .kind = "account", .name = "A", .value = 100 }};
    var day2_accts = [_]snapshot.AccountRow{.{ .kind = "account", .name = "B", .value = 200 }};
    var day3_accts = [_]snapshot.AccountRow{
        .{ .kind = "account", .name = "A", .value = 300 },
        .{ .kind = "account", .name = "B", .value = 400 },
    };

    var t1 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 100 }};
    var t2 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 200 }};
    var t3 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 700 }};

    const mk = struct {
        fn f(y: i16, m: u8, d: u8, totals: []snapshot.TotalRow, accts: []snapshot.AccountRow) snapshot.Snapshot {
            return .{
                .meta = .{
                    .kind = "meta",
                    .snapshot_version = 1,
                    .as_of_date = Date.fromYmd(y, m, d),
                    .captured_at = 0,
                    .zfin_version = "test",
                    .stale_count = 0,
                },
                .totals = totals,
                .tax_types = &.{},
                .accounts = accts,
                .lots = &.{},
            };
        }
    }.f;

    const snaps = [_]snapshot.Snapshot{
        mk(2026, 4, 17, &t1, &day1_accts),
        mk(2026, 4, 18, &t2, &day2_accts),
        mk(2026, 4, 19, &t3, &day3_accts),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const a_series = try extractNamedSeries(testing.allocator, series.points, .accounts, "A");
    defer testing.allocator.free(a_series);
    try testing.expectEqual(@as(f64, 100), a_series[0].value);
    try testing.expectEqual(@as(f64, 0), a_series[1].value); // absent -> 0
    try testing.expectEqual(@as(f64, 300), a_series[2].value);
}

test "extractNamedSeries tax_types: absent days emit 0" {
    var taxes1 = [_]snapshot.TaxTypeRow{.{ .kind = "tax_type", .label = "Taxable", .value = 500 }};
    var taxes2 = [_]snapshot.TaxTypeRow{};
    var tot1 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 500 }};
    var tot2 = [_]snapshot.TotalRow{.{ .kind = "total", .scope = "net_worth", .value = 0 }};

    const mk = struct {
        fn f(d: u8, totals: []snapshot.TotalRow, tt: []snapshot.TaxTypeRow) snapshot.Snapshot {
            return .{
                .meta = .{
                    .kind = "meta",
                    .snapshot_version = 1,
                    .as_of_date = Date.fromYmd(2026, 4, d),
                    .captured_at = 0,
                    .zfin_version = "test",
                    .stale_count = 0,
                },
                .totals = totals,
                .tax_types = tt,
                .accounts = &.{},
                .lots = &.{},
            };
        }
    }.f;

    const snaps = [_]snapshot.Snapshot{
        mk(17, &tot1, &taxes1),
        mk(18, &tot2, &taxes2),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const s = try extractNamedSeries(testing.allocator, series.points, .tax_types, "Taxable");
    defer testing.allocator.free(s);
    try testing.expectEqual(@as(f64, 500), s[0].value);
    try testing.expectEqual(@as(f64, 0), s[1].value);
}

test "computeStats: typical case" {
    const pts = [_]MetricPoint{
        .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
        .{ .date = Date.fromYmd(2026, 4, 18), .value = 900 },
        .{ .date = Date.fromYmd(2026, 4, 19), .value = 1500 },
        .{ .date = Date.fromYmd(2026, 4, 20), .value = 1100 },
    };
    const s = computeStats(&pts).?;
    try testing.expectEqual(@as(f64, 1000), s.first);
    try testing.expectEqual(@as(f64, 1100), s.last);
    try testing.expectEqual(@as(f64, 900), s.min);
    try testing.expectEqual(@as(f64, 1500), s.max);
    try testing.expectEqual(@as(f64, 100), s.delta_abs);
    try testing.expect(s.delta_pct != null);
    try testing.expectApproxEqAbs(@as(f64, 0.10), s.delta_pct.?, 1e-9);
}

test "computeStats: empty input returns null" {
    const empty: []const MetricPoint = &.{};
    try testing.expect(computeStats(empty) == null);
}

test "computeStats: single point — all fields equal" {
    const pts = [_]MetricPoint{.{ .date = Date.fromYmd(2026, 4, 17), .value = 5000 }};
    const s = computeStats(&pts).?;
    try testing.expectEqual(@as(f64, 5000), s.first);
    try testing.expectEqual(@as(f64, 5000), s.last);
    try testing.expectEqual(@as(f64, 5000), s.min);
    try testing.expectEqual(@as(f64, 5000), s.max);
    try testing.expectEqual(@as(f64, 0), s.delta_abs);
    try testing.expectEqual(@as(f64, 0), s.delta_pct.?);
}

test "computeStats: first-zero yields null delta_pct (no div-by-zero)" {
    const pts = [_]MetricPoint{
        .{ .date = Date.fromYmd(2026, 4, 17), .value = 0 },
        .{ .date = Date.fromYmd(2026, 4, 18), .value = 1000 },
    };
    const s = computeStats(&pts).?;
    try testing.expectEqual(@as(f64, 1000), s.delta_abs);
    try testing.expect(s.delta_pct == null);
}

test "computeStats: negative delta" {
    const pts = [_]MetricPoint{
        .{ .date = Date.fromYmd(2026, 4, 17), .value = 1000 },
        .{ .date = Date.fromYmd(2026, 4, 18), .value = 800 },
    };
    const s = computeStats(&pts).?;
    try testing.expectEqual(@as(f64, -200), s.delta_abs);
    try testing.expectApproxEqAbs(@as(f64, -0.20), s.delta_pct.?, 1e-9);
}

test "buildRollupRecords: one row per point, kind discriminator set" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 800, 200),
        fixtureSnapshot(&b2, 2026, 4, 18, 1100, 850, 250),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const rows = try buildRollupRecords(testing.allocator, series.points);
    defer testing.allocator.free(rows);

    try testing.expectEqual(@as(usize, 2), rows.len);
    try testing.expectEqualStrings("rollup", rows[0].kind);
    try testing.expect(rows[0].as_of_date.eql(Date.fromYmd(2026, 4, 17)));
    try testing.expectEqual(@as(f64, 1000), rows[0].net_worth);
    try testing.expectEqual(@as(f64, 800), rows[0].liquid);
    try testing.expectEqual(@as(f64, 200), rows[0].illiquid);
    try testing.expectEqual(@as(f64, 1100), rows[1].net_worth);
}

test "buildRollupRecords: empty input produces empty slice" {
    const rows = try buildRollupRecords(testing.allocator, &.{});
    defer testing.allocator.free(rows);
    try testing.expectEqual(@as(usize, 0), rows.len);
}

// ── pointAtOrBefore ──────────────────────────────────────────

test "pointAtOrBefore: exact / snap-backward / null" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 18, 1100, 750, 350),
        fixtureSnapshot(&b3, 2026, 4, 21, 1200, 800, 400), // gap over weekend
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    // Exact match
    try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2026, 4, 18)).?.net_worth == 1100);
    // Snap backward over weekend: Saturday -> Friday's snapshot
    try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2026, 4, 19)).?.net_worth == 1100);
    // Before all -> null
    try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2026, 4, 1)) == null);
    // After all -> latest
    try testing.expect(pointAtOrBefore(series.points, Date.fromYmd(2099, 1, 1)).?.net_worth == 1200);
}

// ── computeWindowSet ─────────────────────────────────────────

test "computeWindowSet: empty series produces empty rows" {
    const ws = try computeWindowSet(testing.allocator, &.{}, .net_worth, Date.fromYmd(2026, 4, 22));
    defer ws.deinit();
    try testing.expectEqual(@as(usize, 0), ws.rows.len);
}

test "computeWindowSet: 8 period rows + 1 all-time row" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 22, 1500, 1000, 500),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const ws = try computeWindowSet(testing.allocator, series.points, .net_worth, Date.fromYmd(2026, 4, 22));
    defer ws.deinit();

    // 8 HistoricalPeriod windows + 1 all-time
    try testing.expectEqual(@as(usize, 9), ws.rows.len);

    // Last row is all-time (period is null, label matches)
    const all_time = ws.rows[ws.rows.len - 1];
    try testing.expect(all_time.period == null);
    try testing.expectEqualStrings("All-time", all_time.label);
    try testing.expect(all_time.start_value.? == 1000);
    try testing.expect(all_time.end_value == 1500);
    try testing.expect(all_time.delta_abs.? == 500);
    try testing.expectApproxEqAbs(@as(f64, 0.5), all_time.delta_pct.?, 1e-9);
}

test "computeWindowSet: not-enough-history leaves start_value/delta null" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 21, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 22, 1100, 750, 350),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const ws = try computeWindowSet(testing.allocator, series.points, .net_worth, Date.fromYmd(2026, 4, 22));
    defer ws.deinit();

    // First row is 1-day: anchor = 2026-04-21 (exact match). Fully populated.
    try testing.expect(ws.rows[0].period == HistoricalPeriod.@"1D");
    try testing.expect(ws.rows[0].anchor_date != null);
    try testing.expect(ws.rows[0].delta_abs != null);

    // 1-year row (index 4 in timeline_windows: 1D, 1W, 1M, YTD, 1Y, 3Y, 5Y, 10Y)
    // with only 2 days of history: no anchor.
    try testing.expect(ws.rows[4].period == HistoricalPeriod.@"1Y");
    try testing.expect(ws.rows[4].anchor_date == null);
    try testing.expect(ws.rows[4].start_value == null);
    try testing.expect(ws.rows[4].delta_abs == null);
    try testing.expect(ws.rows[4].delta_pct == null);
    // end_value still populated
    try testing.expect(ws.rows[4].end_value == 1100);
}

test "computeWindowSet: YTD anchors to Jan 1 (snaps to prior year's last close)" {
    // Dec 31 2025 snapshot + one in April 2026. YTD on 2026-04-22 should
    // anchor to Dec 31 2025 (pointAtOrBefore snaps Jan 1 2026 back to the
    // prior-year close).
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2025, 12, 31, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 22, 1500, 1000, 500),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const ws = try computeWindowSet(testing.allocator, series.points, .net_worth, Date.fromYmd(2026, 4, 22));
    defer ws.deinit();

    // ytd is index 3 in timeline_windows
    const ytd = ws.rows[3];
    try testing.expect(ytd.period == HistoricalPeriod.ytd);
    try testing.expect(ytd.anchor_date.?.eql(Date.fromYmd(2025, 12, 31)));
    try testing.expect(ytd.start_value.? == 1000);
    try testing.expect(ytd.delta_abs.? == 500);
}

test "computeWindowSet: liquid metric is independent of net_worth" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 22, 1500, 1100, 400),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 4, 22));
    defer ws.deinit();

    const all_time = ws.rows[ws.rows.len - 1];
    try testing.expect(all_time.start_value.? == 700);
    try testing.expect(all_time.end_value == 1100);
    try testing.expect(all_time.delta_abs.? == 400);
}

// ── computeRowDeltas ─────────────────────────────────────────

test "computeRowDeltas: first row has null deltas; others populated" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 18, 1100, 750, 350),
        fixtureSnapshot(&b3, 2026, 4, 19, 1050, 720, 330),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const rows = try computeRowDeltas(testing.allocator, series.points);
    defer testing.allocator.free(rows);

    try testing.expectEqual(@as(usize, 3), rows.len);

    // First row: all deltas null
    try testing.expect(rows[0].d_net_worth == null);
    try testing.expect(rows[0].d_liquid == null);
    try testing.expect(rows[0].d_illiquid == null);

    // Second row: +100 net_worth
    try testing.expect(rows[1].d_net_worth.? == 100);
    try testing.expect(rows[1].d_liquid.? == 50);
    try testing.expect(rows[1].d_illiquid.? == 50);

    // Third row: -50 net_worth
    try testing.expect(rows[2].d_net_worth.? == -50);
}

test "computeRowDeltas: empty input" {
    const rows = try computeRowDeltas(testing.allocator, &.{});
    defer testing.allocator.free(rows);
    try testing.expectEqual(@as(usize, 0), rows.len);
}

// ── selectResolution / aggregatePoints ───────────────────────

test "selectResolution: thresholds" {
    const mk = struct {
        fn f(day_span: i32) []TimelinePoint {
            var out = std.testing.allocator.alloc(TimelinePoint, 2) catch unreachable;
            out[0] = .{
                .as_of_date = Date.fromYmd(2026, 1, 1),
                .net_worth = 0,
                .liquid = 0,
                .illiquid = 0,
                .accounts = &.{},
                .tax_types = &.{},
            };
            out[1] = .{
                .as_of_date = Date.fromYmd(2026, 1, 1).addDays(day_span),
                .net_worth = 0,
                .liquid = 0,
                .illiquid = 0,
                .accounts = &.{},
                .tax_types = &.{},
            };
            return out;
        }
    }.f;

    const p90 = mk(90);
    defer testing.allocator.free(p90);
    try testing.expectEqual(Resolution.daily, selectResolution(p90));

    const p91 = mk(91);
    defer testing.allocator.free(p91);
    try testing.expectEqual(Resolution.weekly, selectResolution(p91));

    const p730 = mk(730);
    defer testing.allocator.free(p730);
    try testing.expectEqual(Resolution.weekly, selectResolution(p730));

    const p731 = mk(731);
    defer testing.allocator.free(p731);
    try testing.expectEqual(Resolution.monthly, selectResolution(p731));

    // Single-point: daily
    try testing.expectEqual(Resolution.daily, selectResolution(p90[0..1]));
}

test "aggregatePoints: daily returns a copy" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 4, 17, 1000, 700, 300),
        fixtureSnapshot(&b2, 2026, 4, 18, 1100, 750, 350),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();
    const out = try aggregatePoints(testing.allocator, series.points, .daily);
    defer testing.allocator.free(out);
    try testing.expectEqual(@as(usize, 2), out.len);
    try testing.expect(out[0].net_worth == 1000);
    try testing.expect(out[1].net_worth == 1100);
}

test "aggregatePoints: weekly rolling, one pick per 7-day bucket from latest" {
    // Span 21 days: expect 3 buckets, one pick each.
    // Generate one point per day so we can see bucket boundaries clearly.
    var points_al: std.ArrayList(TimelinePoint) = .empty;
    defer points_al.deinit(testing.allocator);
    var i: i32 = 0;
    while (i <= 20) : (i += 1) {
        try points_al.append(testing.allocator, .{
            .as_of_date = Date.fromYmd(2026, 4, 1).addDays(i),
            .net_worth = @as(f64, @floatFromInt(1000 + i)),
            .liquid = 0,
            .illiquid = 0,
            .accounts = &.{},
            .tax_types = &.{},
        });
    }
    const out = try aggregatePoints(testing.allocator, points_al.items, .weekly);
    defer testing.allocator.free(out);

    // Last date is 2026-04-21. Buckets anchored from that:
    //   bucket 0: days [15..21] -> pick 2026-04-21 (1020)
    //   bucket 1: days [08..14] -> pick 2026-04-14 (1013)
    //   bucket 2: days [01..07] -> pick 2026-04-07 (1006)
    try testing.expectEqual(@as(usize, 3), out.len);
    try testing.expect(out[0].as_of_date.eql(Date.fromYmd(2026, 4, 7)));
    try testing.expect(out[0].net_worth == 1006);
    try testing.expect(out[1].as_of_date.eql(Date.fromYmd(2026, 4, 14)));
    try testing.expect(out[2].as_of_date.eql(Date.fromYmd(2026, 4, 21)));
    try testing.expect(out[2].net_worth == 1020);
}

test "aggregatePoints: monthly picks latest snapshot in each calendar month" {
    var b1: [3]snapshot.TotalRow = undefined;
    var b2: [3]snapshot.TotalRow = undefined;
    var b3: [3]snapshot.TotalRow = undefined;
    var b4: [3]snapshot.TotalRow = undefined;
    var b5: [3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&b1, 2026, 2, 5, 100, 0, 0),
        fixtureSnapshot(&b2, 2026, 2, 28, 200, 0, 0), // latest Feb
        fixtureSnapshot(&b3, 2026, 3, 1, 300, 0, 0),
        fixtureSnapshot(&b4, 2026, 3, 31, 400, 0, 0), // latest Mar
        fixtureSnapshot(&b5, 2026, 4, 10, 500, 0, 0), // only Apr entry
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();
    const out = try aggregatePoints(testing.allocator, series.points, .monthly);
    defer testing.allocator.free(out);

    try testing.expectEqual(@as(usize, 3), out.len);
    try testing.expect(out[0].as_of_date.eql(Date.fromYmd(2026, 2, 28)));
    try testing.expect(out[0].net_worth == 200);
    try testing.expect(out[1].as_of_date.eql(Date.fromYmd(2026, 3, 31)));
    try testing.expect(out[1].net_worth == 400);
    try testing.expect(out[2].as_of_date.eql(Date.fromYmd(2026, 4, 10)));
    try testing.expect(out[2].net_worth == 500);
}

test "aggregatePoints: empty input returns empty slice" {
    const out_d = try aggregatePoints(testing.allocator, &.{}, .daily);
    defer testing.allocator.free(out_d);
    try testing.expectEqual(@as(usize, 0), out_d.len);

    const out_w = try aggregatePoints(testing.allocator, &.{}, .weekly);
    defer testing.allocator.free(out_w);
    try testing.expectEqual(@as(usize, 0), out_w.len);

    const out_m = try aggregatePoints(testing.allocator, &.{}, .monthly);
    defer testing.allocator.free(out_m);
    try testing.expectEqual(@as(usize, 0), out_m.len);
}

test "aggregateCascading: empty series" {
    const ts = try aggregateCascading(testing.allocator, &.{}, Date.fromYmd(2026, 5, 11));
    defer ts.deinit();
    try testing.expectEqual(@as(usize, 0), ts.buckets.len);
}

test "aggregateCascading: daily tier covers last 14 days" {
    var bufs: [10][3]snapshot.TotalRow = undefined;
    var snaps_arr: [10]snapshot.Snapshot = undefined;
    // Snapshots from 2026-04-30 through 2026-05-09 (10 days).
    snaps_arr[0] = fixtureSnapshot(&bufs[0], 2026, 4, 30, 100, 100, 0);
    snaps_arr[1] = fixtureSnapshot(&bufs[1], 2026, 5, 1, 110, 110, 0);
    snaps_arr[2] = fixtureSnapshot(&bufs[2], 2026, 5, 2, 120, 120, 0);
    snaps_arr[3] = fixtureSnapshot(&bufs[3], 2026, 5, 3, 130, 130, 0);
    snaps_arr[4] = fixtureSnapshot(&bufs[4], 2026, 5, 4, 140, 140, 0);
    snaps_arr[5] = fixtureSnapshot(&bufs[5], 2026, 5, 5, 150, 150, 0);
    snaps_arr[6] = fixtureSnapshot(&bufs[6], 2026, 5, 6, 160, 160, 0);
    snaps_arr[7] = fixtureSnapshot(&bufs[7], 2026, 5, 7, 170, 170, 0);
    snaps_arr[8] = fixtureSnapshot(&bufs[8], 2026, 5, 8, 180, 180, 0);
    snaps_arr[9] = fixtureSnapshot(&bufs[9], 2026, 5, 9, 190, 190, 0);
    const series = try buildSeries(testing.allocator, &snaps_arr);
    defer series.deinit();
    const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
    defer ts.deinit();

    // All 10 should land in the daily tier (days 04-30 to 05-09 are within 13 days of 05-11).
    var daily_count: usize = 0;
    for (ts.buckets) |b| if (b.tier == .daily) {
        daily_count += 1;
    };
    try testing.expectEqual(@as(usize, 10), daily_count);

    // Newest-first: first daily bucket is 2026-05-09.
    try testing.expect(ts.buckets[0].representative_date.eql(Date.fromYmd(2026, 5, 9)));
    try testing.expectEqual(@as(f64, 190), ts.buckets[0].liquid);
}

test "aggregateCascading: weekly tier emitted for older data" {
    var bufs: [3][3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        // Outside daily (>13 days before 2026-05-11): should land in weekly.
        fixtureSnapshot(&bufs[0], 2026, 4, 27, 100, 100, 0),
        fixtureSnapshot(&bufs[1], 2026, 4, 20, 110, 110, 0),
        fixtureSnapshot(&bufs[2], 2026, 4, 13, 120, 120, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();
    const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
    defer ts.deinit();

    var weekly_count: usize = 0;
    for (ts.buckets) |b| if (b.tier == .weekly) {
        weekly_count += 1;
    };
    try testing.expect(weekly_count >= 1);
}

test "aggregateCascading: imported-only buckets flagged" {
    const imp = [_]ImportedHistoryPoint{
        .{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 },
        .{ .date = Date.fromYmd(2014, 12, 26), .liquid = 1_400_000 },
        .{ .date = Date.fromYmd(2024, 6, 1), .liquid = 5_000_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
    defer series.deinit();
    const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
    defer ts.deinit();

    // Yearly buckets for 2014, 2024 should exist and be imported_only.
    var found_2014 = false;
    for (ts.buckets) |b| {
        if (b.tier == .yearly and b.bucket_start.year() == 2014) {
            found_2014 = true;
            try testing.expect(b.imported_only);
            try testing.expectEqual(@as(f64, 1_400_000), b.liquid);
            try testing.expectEqual(@as(f64, 0), b.illiquid);
        }
    }
    try testing.expect(found_2014);
}

test "aggregateCascading: yearly buckets are newest-first" {
    const imp = [_]ImportedHistoryPoint{
        .{ .date = Date.fromYmd(2020, 6, 1), .liquid = 3_000_000 },
        .{ .date = Date.fromYmd(2018, 6, 1), .liquid = 2_500_000 },
        .{ .date = Date.fromYmd(2016, 6, 1), .liquid = 2_000_000 },
        .{ .date = Date.fromYmd(2014, 6, 1), .liquid = 1_500_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
    defer series.deinit();
    const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11));
    defer ts.deinit();

    // Filter to yearly buckets and confirm descending year order.
    var prev_year: ?i16 = null;
    for (ts.buckets) |b| {
        if (b.tier != .yearly) continue;
        if (prev_year) |py| {
            try testing.expect(b.bucket_start.year() < py);
        }
        prev_year = b.bucket_start.year();
    }
}

test "aggregateCascading: monthly stays in current year only" {
    var bufs: [4][3]snapshot.TotalRow = undefined;
    // today = 2026-02-15. Current calendar year (2026) has data
    // only for Jan and Feb. Monthly should NOT dip into 2025;
    // the quarterly tier handles prior-year buckets.
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&bufs[0], 2025, 11, 28, 100, 100, 0),
        fixtureSnapshot(&bufs[1], 2025, 12, 26, 110, 110, 0),
        fixtureSnapshot(&bufs[2], 2026, 1, 30, 120, 120, 0),
        fixtureSnapshot(&bufs[3], 2026, 2, 13, 130, 130, 0), // outside daily (today=2026-02-15, daily_start=2026-02-02), so this is weekly+
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();
    const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 2, 15));
    defer ts.deinit();

    var has_jan_2026 = false;
    var has_dec_2025 = false;
    var has_q4_2025 = false;
    for (ts.buckets) |b| {
        if (b.tier == .monthly and b.bucket_start.year() == 2026 and b.bucket_start.month() == 1) has_jan_2026 = true;
        if (b.tier == .monthly and b.bucket_start.year() == 2025) has_dec_2025 = true;
        if (b.tier == .quarterly and b.bucket_start.year() == 2025 and b.bucket_start.month() == 10) has_q4_2025 = true;
    }
    try testing.expect(has_jan_2026);
    try testing.expect(!has_dec_2025); // monthly does NOT dip into 2025
    try testing.expect(has_q4_2025);
}

test "computeBucketDeltas: Δ on row i is current minus older neighbor" {
    const buckets = [_]TierBucket{
        .{ // Newest first.
            .tier = .yearly,
            .bucket_start = Date.fromYmd(2024, 1, 1),
            .bucket_end = Date.fromYmd(2024, 12, 31),
            .representative_date = Date.fromYmd(2024, 12, 31),
            .liquid = 6_000_000,
            .illiquid = 0,
            .net_worth = 6_000_000,
            .imported_only = true,
            .series_slice = &.{},
        },
        .{
            .tier = .yearly,
            .bucket_start = Date.fromYmd(2023, 1, 1),
            .bucket_end = Date.fromYmd(2023, 12, 31),
            .representative_date = Date.fromYmd(2023, 12, 31),
            .liquid = 5_000_000,
            .illiquid = 0,
            .net_worth = 5_000_000,
            .imported_only = true,
            .series_slice = &.{},
        },
        .{
            .tier = .yearly,
            .bucket_start = Date.fromYmd(2022, 1, 1),
            .bucket_end = Date.fromYmd(2022, 12, 31),
            .representative_date = Date.fromYmd(2022, 12, 31),
            .liquid = 4_000_000,
            .illiquid = 0,
            .net_worth = 4_000_000,
            .imported_only = true,
            .series_slice = &.{},
        },
    };
    const deltas = try computeBucketDeltas(testing.allocator, &buckets);
    defer testing.allocator.free(deltas);

    // Row 0 (2024) Δ vs row 1 (2023): +1M (gained from 2023 to 2024).
    try testing.expectEqual(@as(?f64, 1_000_000), deltas[0].delta_liquid);
    // Row 1 (2023) Δ vs row 2 (2022): +1M.
    try testing.expectEqual(@as(?f64, 1_000_000), deltas[1].delta_liquid);
    // Row 2 (2022): oldest, no neighbor.
    try testing.expectEqual(@as(?f64, null), deltas[2].delta_liquid);
    // illiquid Δ across imported_only neighbors → null.
    try testing.expectEqual(@as(?f64, null), deltas[0].delta_illiquid);
}

test "childBuckets: yearly produces 4 quarterly children" {
    const imp = [_]ImportedHistoryPoint{
        .{ .date = Date.fromYmd(2024, 2, 28), .liquid = 1_000_000 },
        .{ .date = Date.fromYmd(2024, 5, 31), .liquid = 2_000_000 },
        .{ .date = Date.fromYmd(2024, 8, 30), .liquid = 3_000_000 },
        .{ .date = Date.fromYmd(2024, 11, 29), .liquid = 4_000_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
    defer series.deinit();

    const parent: TierBucket = .{
        .tier = .yearly,
        .bucket_start = Date.fromYmd(2024, 1, 1),
        .bucket_end = Date.fromYmd(2024, 12, 31),
        .representative_date = Date.fromYmd(2024, 11, 29),
        .liquid = 4_000_000,
        .illiquid = 0,
        .net_worth = 4_000_000,
        .imported_only = true,
        .series_slice = series.points,
    };

    const children = try childBuckets(testing.allocator, parent);
    defer testing.allocator.free(children);

    try testing.expectEqual(@as(usize, 4), children.len);
    // Newest-first: Q4, Q3, Q2, Q1.
    try testing.expect(children[0].tier == .quarterly);
    try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2024, 10, 1)));
    try testing.expect(children[3].bucket_start.eql(Date.fromYmd(2024, 1, 1)));
    // Q4's value comes from the latest 2024-11-29 datum.
    try testing.expectEqual(@as(f64, 4_000_000), children[0].liquid);
}

test "childBuckets: quarterly produces 3 monthly children" {
    const imp = [_]ImportedHistoryPoint{
        .{ .date = Date.fromYmd(2024, 1, 31), .liquid = 1_000_000 },
        .{ .date = Date.fromYmd(2024, 2, 28), .liquid = 2_000_000 },
        .{ .date = Date.fromYmd(2024, 3, 31), .liquid = 3_000_000 },
    };
    const series = try buildMergedSeries(testing.allocator, &.{}, &imp);
    defer series.deinit();

    const parent: TierBucket = .{
        .tier = .quarterly,
        .bucket_start = Date.fromYmd(2024, 1, 1),
        .bucket_end = Date.fromYmd(2024, 3, 31),
        .representative_date = Date.fromYmd(2024, 3, 31),
        .liquid = 3_000_000,
        .illiquid = 0,
        .net_worth = 3_000_000,
        .imported_only = true,
        .series_slice = series.points,
    };

    const children = try childBuckets(testing.allocator, parent);
    defer testing.allocator.free(children);

    try testing.expectEqual(@as(usize, 3), children.len);
    // Newest-first: Mar, Feb, Jan.
    try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2024, 3, 1)));
    try testing.expect(children[2].bucket_start.eql(Date.fromYmd(2024, 1, 1)));
}

test "childBuckets: weekly produces daily children" {
    var bufs: [3][3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&bufs[0], 2026, 4, 13, 100, 100, 0),
        fixtureSnapshot(&bufs[1], 2026, 4, 14, 110, 110, 0),
        fixtureSnapshot(&bufs[2], 2026, 4, 15, 120, 120, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const parent: TierBucket = .{
        .tier = .weekly,
        .bucket_start = Date.fromYmd(2026, 4, 13),
        .bucket_end = Date.fromYmd(2026, 4, 19),
        .representative_date = Date.fromYmd(2026, 4, 15),
        .liquid = 120,
        .illiquid = 120,
        .net_worth = 120,
        .imported_only = false,
        .series_slice = series.points,
    };

    const children = try childBuckets(testing.allocator, parent);
    defer testing.allocator.free(children);

    try testing.expectEqual(@as(usize, 3), children.len);
    // Newest-first.
    try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2026, 4, 15)));
    try testing.expect(children[2].bucket_start.eql(Date.fromYmd(2026, 4, 13)));
    try testing.expect(children[0].tier == .daily);
}

test "childBuckets: daily has no children" {
    const parent: TierBucket = .{
        .tier = .daily,
        .bucket_start = Date.fromYmd(2026, 5, 8),
        .bucket_end = Date.fromYmd(2026, 5, 8),
        .representative_date = Date.fromYmd(2026, 5, 8),
        .liquid = 100,
        .illiquid = 0,
        .net_worth = 100,
        .imported_only = false,
        .series_slice = &.{},
    };
    const children = try childBuckets(testing.allocator, parent);
    defer testing.allocator.free(children);
    try testing.expectEqual(@as(usize, 0), children.len);
}

test "finerTier transitions" {
    try testing.expectEqual(Tier.quarterly, finerTier(.yearly).?);
    try testing.expectEqual(Tier.monthly, finerTier(.quarterly).?);
    try testing.expectEqual(Tier.weekly, finerTier(.monthly).?);
    try testing.expectEqual(Tier.daily, finerTier(.weekly).?);
    try testing.expectEqual(@as(?Tier, null), finerTier(.daily));
}

test "annualizedFromPct: 1-year window equals delta_pct" {
    // Anchor 365 days back, 10% gain. CAGR = 10% / yr exactly
    // (365 days / 365.25 = 0.9993 years; close enough that the
    // result is essentially +10%).
    const as_of = Date.fromYmd(2026, 5, 11);
    const anchor = Date.fromYmd(2025, 5, 11);
    const ann = annualizedFromPct(0.10, anchor, as_of).?;
    try testing.expectApproxEqAbs(0.10, ann, 0.001);
}

test "annualizedFromPct: 3-year doubling yields ~26% CAGR" {
    // 100% gain over 3 years = 2^(1/3) - 1 ≈ 0.2599.
    const as_of = Date.fromYmd(2026, 5, 11);
    const anchor = Date.fromYmd(2023, 5, 11);
    const ann = annualizedFromPct(1.0, anchor, as_of).?;
    try testing.expectApproxEqAbs(0.2599, ann, 0.005);
}

test "annualizedFromPct: 10-year +481.49% yields ~19.27% CAGR" {
    // From the user's actual data: 10Y cumulative return.
    const as_of = Date.fromYmd(2026, 5, 11);
    const anchor = Date.fromYmd(2016, 5, 11);
    const ann = annualizedFromPct(4.8149, anchor, as_of).?;
    try testing.expectApproxEqAbs(0.1927, ann, 0.005);
}

test "annualizedFromPct: null delta_pct → null" {
    const as_of = Date.fromYmd(2026, 5, 11);
    const anchor = Date.fromYmd(2025, 5, 11);
    try testing.expectEqual(@as(?f64, null), annualizedFromPct(null, anchor, as_of));
}

test "annualizedFromPct: anchor in future → null" {
    const as_of = Date.fromYmd(2026, 5, 11);
    const future = Date.fromYmd(2027, 5, 11);
    try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, future, as_of));
}

test "annualizedFromPct: same-day anchor → null (years <= 0)" {
    const as_of = Date.fromYmd(2026, 5, 11);
    try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of));
}

test "annualizedFromPct: equity-going-negative case → null" {
    // delta_pct = -1.5 means we lost 150% — impossible from
    // positive starting equity, but defensive.
    const as_of = Date.fromYmd(2026, 5, 11);
    const anchor = Date.fromYmd(2025, 5, 11);
    try testing.expectEqual(@as(?f64, null), annualizedFromPct(-1.5, anchor, as_of));
}

test "computeWindowSet: populates annualized_pct on real-ish data" {
    // 4 snapshots: 2014-07-03 ($1.28M), 2020-05-11 ($4.0M),
    // 2025-05-11 ($7.4M), 2026-05-11 ($8.6M).
    var bufs: [4][3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&bufs[0], 2014, 7, 3, 1_280_000, 1_280_000, 0),
        fixtureSnapshot(&bufs[1], 2020, 5, 11, 4_000_000, 4_000_000, 0),
        fixtureSnapshot(&bufs[2], 2025, 5, 11, 7_400_000, 7_400_000, 0),
        fixtureSnapshot(&bufs[3], 2026, 5, 11, 8_600_000, 8_600_000, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 5, 11));
    defer ws.deinit();

    // Find the 1Y row and the all-time row.
    var found_1y = false;
    var found_all = false;
    for (ws.rows) |row| {
        if (row.period) |p| {
            if (p == .@"1Y") {
                found_1y = true;
                // 1Y: $7.4M → $8.6M = ~16.2% cumulative.
                // Anchor at 2025-05-11, today 2026-05-11 = exactly 365 days.
                const ann = row.annualized_pct.?;
                try testing.expectApproxEqAbs(0.1622, ann, 0.005);
            }
        } else {
            // All-time row.
            found_all = true;
            // ~11.85 years, +$1.28M → +$8.6M = ~572% cumulative
            // → CAGR ~17.4%
            const ann = row.annualized_pct.?;
            try testing.expectApproxEqAbs(0.174, ann, 0.01);
        }
    }
    try testing.expect(found_1y);
    try testing.expect(found_all);
}

test "computeWindowSet: missing anchor leaves annualized_pct null" {
    // A series too short for a 5Y anchor: should produce null
    // both for delta_pct and annualized_pct on that row.
    var bufs: [2][3]snapshot.TotalRow = undefined;
    const snaps = [_]snapshot.Snapshot{
        fixtureSnapshot(&bufs[0], 2025, 1, 1, 1000, 1000, 0),
        fixtureSnapshot(&bufs[1], 2026, 5, 11, 2000, 2000, 0),
    };
    const series = try buildSeries(testing.allocator, &snaps);
    defer series.deinit();

    const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 5, 11));
    defer ws.deinit();

    for (ws.rows) |row| {
        if (row.period) |p| {
            if (p == .@"5Y" or p == .@"10Y") {
                try testing.expectEqual(@as(?f64, null), row.annualized_pct);
            }
        }
    }
}
