/// Benchmark comparison and portfolio weighted return calculations.
///
/// Produces:
///   - Weighted benchmark returns (SPY + AGG at the portfolio's stock/bond split)
///   - Per-period portfolio weighted trailing returns (straight average)
///   - Conservative weighted return: SUM(weight_i * MIN(3Y_i, 5Y_i, 10Y_i))
///
/// The conservative estimate uses the minimum of longer-term trailing returns
/// per position, excluding 1-year (too noisy). This matches the spreadsheet
/// methodology used for forward-looking projections.
const std = @import("std");
const performance = @import("performance.zig");
const TrailingReturns = performance.TrailingReturns;
const PerformanceResult = performance.PerformanceResult;
const Allocation = @import("valuation.zig").Allocation;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;

// ── Conservative return estimation defaults ────────────────────
// These will eventually move to projections.srf config.

/// Maximum return any single position can contribute (null = no cap).
pub const default_return_cap: ?f64 = null;

/// Whether to exclude 1-year returns from the conservative MIN calculation.
pub const default_exclude_1y_from_min: bool = true;

// ── Result types ───────────────────────────────────────────────

/// Trailing returns for each standard period, as simple f64 values (annualized).
pub const ReturnsByPeriod = struct {
    one_year: ?f64 = null,
    three_year: ?f64 = null,
    five_year: ?f64 = null,
    ten_year: ?f64 = null,
    /// Weekly return (non-annualized, just the 1-week price change).
    week: ?f64 = null,
};

/// Full benchmark comparison result.
pub const BenchmarkComparison = struct {
    /// Stock benchmark (e.g. SPY) returns at each period.
    stock_returns: ReturnsByPeriod,
    /// Bond benchmark (e.g. AGG) returns at each period.
    bond_returns: ReturnsByPeriod,
    /// Weighted benchmark: stock_pct * stock + bond_pct * bond.
    benchmark_returns: ReturnsByPeriod,
    /// Portfolio weighted average returns (straight weight * return per period).
    portfolio_returns: ReturnsByPeriod,
    /// Conservative estimate: SUM(weight * MIN(3Y, 5Y, 10Y)) per position.
    conservative_return: f64,
    /// Actual stock allocation (fraction).
    stock_pct: f64,
    /// Actual bond allocation (fraction, includes cash and CDs).
    bond_pct: f64,
};

/// Per-position data needed for weighted return calculations.
/// `returns.week` carries the 1-week return, populated automatically
/// by `performance.trailingReturns`.
pub const PositionReturn = struct {
    symbol: []const u8,
    weight: f64,
    returns: TrailingReturns,
};

// ── Allocation split ───────────────────────────────────────────

/// Result of deriving the equity / fixed-income / cash / other allocation split.
pub const AllocationSplit = struct {
    /// Fraction of portfolio in equities (0.0–1.0). Sum of every
    /// classification entry whose `bucketSector(sector)` is "Equity",
    /// weighted by `entry.pct`.
    stock_pct: f64,
    /// Fraction of portfolio in fixed income (0.0–1.0). Excludes
    /// cash. The header line displays cash separately as `cash_pct`.
    bond_pct: f64,
    /// Fraction of portfolio in cash + CDs + fund-internal cash
    /// equivalents (0.0–1.0).
    cash_pct: f64,
    /// Fraction of portfolio in derivatives, real property,
    /// sentinels, and unrecognized sectors (0.0–1.0).
    other_pct: f64,
    /// Total dollar value classified as fixed income (excludes cash).
    bond_value: f64,
    /// Total cash + CD face value + fund-internal cash sleeves.
    cash_cd_value: f64,
    /// Total market value that could not be classified (no metadata entry).
    unclassified_value: f64,
};

/// Derive the equity / fixed-income / cash / other allocation
/// split from portfolio allocations and classification metadata.
///
/// For each allocation, every matching classification entry
/// contributes `market_value × (pct / 100)` into the bucket
/// returned by `analysis.bucketSector(entry.sector)`. This means:
///
///   - Multi-sector funds (e.g. FAGIX with 48% Debt / Corporate
///     and 22% Equity / Corporate) split correctly across buckets
///     proportional to their NPORT-P sector decomposition.
///   - Pure-debt funds (VBTLX) land in `bond_pct` even when their
///     `asset_class` is `Fund` rather than `Bonds`.
///   - GICS-sectored stocks (NVDA → Technology) land in `stock_pct`.
///   - Derivatives, real property, and sentinel sectors land in
///     `other_pct` and are silently excluded from the binary
///     stock/bond header.
///
/// Negative weights from leveraged funds (PTY's
/// `Repurchase Agreement / Other` repo liability) flow through
/// honestly into bucket math. Diluted across a diversified
/// portfolio, the visual effect is negligible.
///
/// Literal cash + CDs are added directly to `cash_pct` (and
/// `cash_cd_value`). Allocations not found in `classifications`
/// are reported via `unclassified_value`.
pub fn deriveAllocationSplit(
    allocations: []const Allocation,
    classifications: []const ClassificationEntry,
    total_value: f64,
    cash_value: f64,
    cd_value: f64,
) AllocationSplit {
    const analysis = @import("analysis.zig");

    var stock_value: f64 = 0;
    var bond_value: f64 = 0;
    var cash_classified_value: f64 = 0;
    var other_value: f64 = 0;
    var classified_value: f64 = 0;

    for (allocations) |a| {
        var found = false;
        for (classifications) |entry| {
            if (!std.mem.eql(u8, entry.symbol, a.symbol)) continue;
            found = true;
            const portion = a.market_value * (entry.pct / 100.0);
            // Bucket via `sector` if present (richer signal).
            // Fall back to `asset_class` for legacy hand-written
            // entries with no sector. Last resort: Other.
            const bucket = if (entry.sector) |s|
                analysis.bucketSector(s)
            else if (entry.asset_class) |ac|
                analysis.bucketAssetClass(ac)
            else
                analysis.bucket_other;
            if (std.mem.eql(u8, bucket, analysis.bucket_equity)) {
                stock_value += portion;
            } else if (std.mem.eql(u8, bucket, analysis.bucket_fixed_income)) {
                bond_value += portion;
            } else if (std.mem.eql(u8, bucket, analysis.bucket_cash)) {
                cash_classified_value += portion;
            } else {
                other_value += portion;
            }
        }
        if (found) {
            classified_value += a.market_value;
        }
    }

    const literal_cash = cash_value + cd_value;
    const total_cash = cash_classified_value + literal_cash;

    // Unclassified = allocations not found in classifications (options, new positions, etc.)
    // Note: cash/CDs are not in allocations, so total_value includes them separately.
    const unclassified_value = total_value - classified_value - literal_cash;

    if (total_value <= 0) {
        // Empty portfolio: fall back to a sensible default for
        // benchmark blending math (75/25 stock/bond).
        return .{
            .stock_pct = 0.75,
            .bond_pct = 0.25,
            .cash_pct = 0,
            .other_pct = 0,
            .bond_value = 0,
            .cash_cd_value = 0,
            .unclassified_value = 0,
        };
    }

    return .{
        .stock_pct = stock_value / total_value,
        .bond_pct = bond_value / total_value,
        .cash_pct = total_cash / total_value,
        .other_pct = other_value / total_value,
        .bond_value = bond_value,
        .cash_cd_value = total_cash,
        .unclassified_value = @max(unclassified_value, 0),
    };
}

// ── Computation functions ──────────────────────────────────────

/// Extract the annualized return from a PerformanceResult, if available.
fn annualized(pr: ?PerformanceResult) ?f64 {
    const r = pr orelse return null;
    return r.annualized_return orelse r.total_return;
}

/// Convert TrailingReturns to simple f64 ReturnsByPeriod.
pub fn toReturnsByPeriod(tr: TrailingReturns) ReturnsByPeriod {
    return .{
        .one_year = annualized(tr.one_year),
        .three_year = annualized(tr.three_year),
        .five_year = annualized(tr.five_year),
        .ten_year = annualized(tr.ten_year),
        .week = tr.week,
    };
}

/// Compute the weighted average of two ReturnsByPeriod values.
fn blendReturns(a: ReturnsByPeriod, a_weight: f64, b: ReturnsByPeriod, b_weight: f64) ReturnsByPeriod {
    return .{
        .one_year = blendOptional(a.one_year, a_weight, b.one_year, b_weight),
        .three_year = blendOptional(a.three_year, a_weight, b.three_year, b_weight),
        .five_year = blendOptional(a.five_year, a_weight, b.five_year, b_weight),
        .ten_year = blendOptional(a.ten_year, a_weight, b.ten_year, b_weight),
        .week = blendOptional(a.week, a_weight, b.week, b_weight),
    };
}

fn blendOptional(a: ?f64, a_w: f64, b: ?f64, b_w: f64) ?f64 {
    if (a != null and b != null)
        return a.? * a_w + b.? * b_w;
    if (a != null) return a.? * a_w;
    if (b != null) return b.? * b_w;
    return null;
}

/// Compute portfolio weighted average returns across all positions.
/// Each position's return is weighted by its portfolio weight.
pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeriod {
    var result = ReturnsByPeriod{};
    var sum_1y: f64 = 0;
    var w_1y: f64 = 0;
    var sum_3y: f64 = 0;
    var w_3y: f64 = 0;
    var sum_5y: f64 = 0;
    var w_5y: f64 = 0;
    var sum_10y: f64 = 0;
    var w_10y: f64 = 0;
    var sum_wk: f64 = 0;
    var w_wk: f64 = 0;

    for (positions) |pos| {
        if (annualized(pos.returns.one_year)) |r| {
            sum_1y += pos.weight * r;
            w_1y += pos.weight;
        }
        if (annualized(pos.returns.three_year)) |r| {
            sum_3y += pos.weight * r;
            w_3y += pos.weight;
        }
        if (annualized(pos.returns.five_year)) |r| {
            sum_5y += pos.weight * r;
            w_5y += pos.weight;
        }
        if (annualized(pos.returns.ten_year)) |r| {
            sum_10y += pos.weight * r;
            w_10y += pos.weight;
        }
        if (pos.returns.week) |r| {
            sum_wk += pos.weight * r;
            w_wk += pos.weight;
        }
    }

    // Normalize by total weight for each period (handles positions with missing data)
    if (w_1y > 0) result.one_year = sum_1y / w_1y;
    if (w_3y > 0) result.three_year = sum_3y / w_3y;
    if (w_5y > 0) result.five_year = sum_5y / w_5y;
    if (w_10y > 0) result.ten_year = sum_10y / w_10y;
    if (w_wk > 0) result.week = sum_wk / w_wk;

    return result;
}

/// Compute the conservative weighted return estimate.
///
/// For each position: take MIN(3Y, 5Y, 10Y) annualized return (skipping 1Y).
/// Apply optional cap. Multiply by weight. Sum across all positions.
/// Normalize by total weight of positions that had at least one valid period.
pub fn conservativeWeightedReturn(
    positions: []const PositionReturn,
    return_cap: ?f64,
    exclude_1y: bool,
) f64 {
    var total: f64 = 0;
    var total_weight: f64 = 0;

    for (positions) |pos| {
        var min_return: ?f64 = null;

        // Include 1Y only if not excluded
        if (!exclude_1y) {
            if (annualized(pos.returns.one_year)) |r| {
                min_return = r;
            }
        }

        if (annualized(pos.returns.three_year)) |r| {
            min_return = if (min_return) |m| @min(m, r) else r;
        }
        if (annualized(pos.returns.five_year)) |r| {
            min_return = if (min_return) |m| @min(m, r) else r;
        }
        if (annualized(pos.returns.ten_year)) |r| {
            min_return = if (min_return) |m| @min(m, r) else r;
        }

        if (min_return) |mr| {
            // Apply cap if configured
            const capped = if (return_cap) |cap| @min(mr, cap) else mr;
            total += pos.weight * capped;
            total_weight += pos.weight;
        }
    }

    if (total_weight > 0) return total / total_weight;
    return 0;
}

/// Build a full benchmark comparison from component data.
pub fn buildComparison(
    stock_trailing: TrailingReturns,
    bond_trailing: TrailingReturns,
    stock_pct: f64,
    bond_pct: f64,
    positions: []const PositionReturn,
) BenchmarkComparison {
    // `stock_trailing.week` and `bond_trailing.week` propagate
    // through `toReturnsByPeriod` automatically — see
    // `performance.trailingReturns`, which populates the field
    // alongside the longer trailing periods.
    const stock_r = toReturnsByPeriod(stock_trailing);
    const bond_r = toReturnsByPeriod(bond_trailing);

    const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct);
    const portfolio = portfolioWeightedReturns(positions);
    const conservative = conservativeWeightedReturn(positions, default_return_cap, default_exclude_1y_from_min);

    return .{
        .stock_returns = stock_r,
        .bond_returns = bond_r,
        .benchmark_returns = benchmark,
        .portfolio_returns = portfolio,
        .conservative_return = conservative,
        .stock_pct = stock_pct,
        .bond_pct = bond_pct,
    };
}

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

const Date = @import("../Date.zig");

fn makePR(total: f64, ann: ?f64) PerformanceResult {
    return .{
        .total_return = total,
        .annualized_return = ann,
        .from = Date.fromYmd(2020, 1, 1),
        .to = Date.fromYmd(2024, 1, 1),
    };
}

test "toReturnsByPeriod extracts annualized returns" {
    const tr = TrailingReturns{
        .one_year = makePR(0.10, 0.10),
        .three_year = makePR(0.30, 0.09),
        .five_year = makePR(0.50, 0.085),
        .ten_year = null,
    };
    const r = toReturnsByPeriod(tr);
    try std.testing.expectApproxEqAbs(@as(f64, 0.10), r.one_year.?, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.09), r.three_year.?, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.085), r.five_year.?, 0.001);
    try std.testing.expect(r.ten_year == null);
}

test "blendReturns weighted average" {
    const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = 0.15 };
    const b = ReturnsByPeriod{ .one_year = 0.05, .three_year = 0.04 };
    const result = blendReturns(a, 0.75, b, 0.25);
    // 0.75 * 0.20 + 0.25 * 0.05 = 0.1625
    try std.testing.expectApproxEqAbs(@as(f64, 0.1625), result.one_year.?, 0.0001);
    // 0.75 * 0.15 + 0.25 * 0.04 = 0.1225
    try std.testing.expectApproxEqAbs(@as(f64, 0.1225), result.three_year.?, 0.0001);
}

test "blendReturns handles null" {
    const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = null };
    const b = ReturnsByPeriod{ .one_year = null, .three_year = 0.04 };
    const result = blendReturns(a, 0.75, b, 0.25);
    // Only a has 1Y: 0.20 * 0.75 = 0.15
    try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.one_year.?, 0.0001);
    // Only b has 3Y: 0.04 * 0.25 = 0.01
    try std.testing.expectApproxEqAbs(@as(f64, 0.01), result.three_year.?, 0.0001);
}

test "portfolioWeightedReturns basic" {
    const positions = [_]PositionReturn{
        .{ .symbol = "SPY", .weight = 0.60, .returns = .{
            .one_year = makePR(0.20, 0.20),
            .three_year = makePR(0.50, 0.15),
        } },
        .{ .symbol = "AGG", .weight = 0.40, .returns = .{
            .one_year = makePR(0.05, 0.05),
            .three_year = makePR(0.12, 0.04),
        } },
    };
    const r = portfolioWeightedReturns(&positions);
    // 1Y: (0.60 * 0.20 + 0.40 * 0.05) / (0.60 + 0.40) = 0.14
    try std.testing.expectApproxEqAbs(@as(f64, 0.14), r.one_year.?, 0.001);
    // 3Y: (0.60 * 0.15 + 0.40 * 0.04) / (0.60 + 0.40) = 0.106
    try std.testing.expectApproxEqAbs(@as(f64, 0.106), r.three_year.?, 0.001);
}

test "portfolioWeightedReturns normalizes by available weight" {
    // Position B has no 3Y data — should normalize by weight of those that do
    const positions = [_]PositionReturn{
        .{ .symbol = "SPY", .weight = 0.60, .returns = .{
            .one_year = makePR(0.20, 0.20),
            .three_year = makePR(0.50, 0.15),
        } },
        .{ .symbol = "NEW", .weight = 0.40, .returns = .{
            .one_year = makePR(0.30, 0.30),
            .three_year = null,
        } },
    };
    const r = portfolioWeightedReturns(&positions);
    // 1Y: (0.60 * 0.20 + 0.40 * 0.30) / 1.0 = 0.24
    try std.testing.expectApproxEqAbs(@as(f64, 0.24), r.one_year.?, 0.001);
    // 3Y: (0.60 * 0.15) / 0.60 = 0.15 (only SPY contributes)
    try std.testing.expectApproxEqAbs(@as(f64, 0.15), r.three_year.?, 0.001);
}

test "conservativeWeightedReturn uses MIN of 3Y/5Y/10Y" {
    const positions = [_]PositionReturn{
        .{
            .symbol = "NVDA",
            .weight = 0.05,
            .returns = .{
                .one_year = makePR(0.32, 0.32), // excluded from MIN
                .three_year = makePR(4.0, 1.28), // 128% (highest)
                .five_year = makePR(10.0, 0.69), // 69% (lowest)
                .ten_year = makePR(50.0, 0.74), // 74%
            },
        },
        .{ .symbol = "SPY", .weight = 0.95, .returns = .{
            .three_year = makePR(0.50, 0.15),
            .five_year = makePR(0.80, 0.12),
            .ten_year = makePR(2.0, 0.14),
        } },
    };
    const result = conservativeWeightedReturn(&positions, null, true);
    // NVDA: MIN(1.28, 0.69, 0.74) = 0.69, weight 0.05
    // SPY: MIN(0.15, 0.12, 0.14) = 0.12, weight 0.95
    // Total: (0.05 * 0.69 + 0.95 * 0.12) / (0.05 + 0.95) = 0.0345 + 0.114 = 0.1485
    try std.testing.expectApproxEqAbs(@as(f64, 0.1485), result, 0.001);
}

test "conservativeWeightedReturn with cap" {
    const positions = [_]PositionReturn{
        .{ .symbol = "NVDA", .weight = 1.0, .returns = .{
            .three_year = makePR(4.0, 1.28),
            .five_year = makePR(10.0, 0.69),
            .ten_year = makePR(50.0, 0.74),
        } },
    };
    // Without cap: MIN(1.28, 0.69, 0.74) = 0.69
    const uncapped = conservativeWeightedReturn(&positions, null, true);
    try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped, 0.001);

    // With 30% cap: MIN(1.28, 0.69, 0.74) = 0.69, then capped to 0.30
    const capped = conservativeWeightedReturn(&positions, 0.30, true);
    try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped, 0.001);
}

test "conservativeWeightedReturn includes 1Y when not excluded" {
    const positions = [_]PositionReturn{
        .{
            .symbol = "SPY",
            .weight = 1.0,
            .returns = .{
                .one_year = makePR(0.05, 0.05), // lowest
                .three_year = makePR(0.50, 0.15),
                .five_year = makePR(0.80, 0.12),
            },
        },
    };
    // With exclude_1y=true: MIN(0.15, 0.12) = 0.12
    const excluded = conservativeWeightedReturn(&positions, null, true);
    try std.testing.expectApproxEqAbs(@as(f64, 0.12), excluded, 0.001);

    // With exclude_1y=false: MIN(0.05, 0.15, 0.12) = 0.05
    const included = conservativeWeightedReturn(&positions, null, false);
    try std.testing.expectApproxEqAbs(@as(f64, 0.05), included, 0.001);
}

test "buildComparison produces consistent results" {
    const stock_tr = TrailingReturns{
        .one_year = makePR(0.23, 0.23),
        .three_year = makePR(0.60, 0.17),
    };
    const bond_tr = TrailingReturns{
        .one_year = makePR(0.04, 0.04),
        .three_year = makePR(0.10, 0.03),
    };
    const positions = [_]PositionReturn{
        .{ .symbol = "VTI", .weight = 0.80, .returns = .{
            .one_year = makePR(0.22, 0.22),
            .three_year = makePR(0.55, 0.16),
            .five_year = makePR(0.80, 0.13),
        } },
        .{ .symbol = "BND", .weight = 0.20, .returns = .{
            .one_year = makePR(0.03, 0.03),
            .three_year = makePR(0.09, 0.03),
            .five_year = makePR(0.10, 0.02),
        } },
    };
    const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions);

    // Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863
    try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001);
    // Portfolio 1Y: (0.80 * 0.22 + 0.20 * 0.03) / 1.0 = 0.182
    try std.testing.expectApproxEqAbs(@as(f64, 0.182), result.portfolio_returns.one_year.?, 0.001);
    // Conservative: VTI MIN(0.16, 0.13) = 0.13 * 0.80 + BND MIN(0.03, 0.02) = 0.02 * 0.20
    // = 0.104 + 0.004 = 0.108
    try std.testing.expectApproxEqAbs(@as(f64, 0.108), result.conservative_return, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.77), result.stock_pct, 0.001);
}

test "conservativeWeightedReturn with no valid periods" {
    const positions = [_]PositionReturn{
        .{ .symbol = "NEW", .weight = 1.0, .returns = .{
            .one_year = makePR(0.10, 0.10),
        } },
    };
    // exclude_1y=true, and only 1Y data available → no valid periods
    const result = conservativeWeightedReturn(&positions, null, true);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
}

test "portfolioWeightedReturns empty positions" {
    const positions = [_]PositionReturn{};
    const r = portfolioWeightedReturns(&positions);
    try std.testing.expect(r.one_year == null);
    try std.testing.expect(r.three_year == null);
    try std.testing.expect(r.week == null);
}

test "portfolioWeightedReturns aggregates week alongside annualized periods" {
    const positions = [_]PositionReturn{
        .{ .symbol = "VTI", .weight = 0.60, .returns = .{
            .one_year = makePR(0.20, 0.20),
            .week = 0.02,
        } },
        .{ .symbol = "BND", .weight = 0.40, .returns = .{
            .one_year = makePR(0.03, 0.03),
            .week = -0.01,
        } },
    };
    const r = portfolioWeightedReturns(&positions);
    // Weighted week: 0.60 * 0.02 + 0.40 * -0.01 = 0.008
    try std.testing.expectApproxEqAbs(@as(f64, 0.008), r.week.?, 0.0001);
    // 1Y still works alongside.
    try std.testing.expectApproxEqAbs(@as(f64, 0.132), r.one_year.?, 0.0001);
}

test "portfolioWeightedReturns week handles null on some positions" {
    // Position B is missing week data — it shouldn't poison A's
    // contribution. Aggregate normalizes by weight of positions
    // that DID supply week.
    const positions = [_]PositionReturn{
        .{ .symbol = "A", .weight = 0.50, .returns = .{ .week = 0.04 } },
        .{ .symbol = "B", .weight = 0.50, .returns = .{} },
    };
    const r = portfolioWeightedReturns(&positions);
    // Only A had a week reading: 0.04 * 0.50 / 0.50 = 0.04
    try std.testing.expectApproxEqAbs(@as(f64, 0.04), r.week.?, 0.0001);
}

test "annualized falls back to total_return" {
    // When annualized_return is null, should use total_return
    const pr = PerformanceResult{
        .total_return = 0.15,
        .annualized_return = null,
        .from = Date.fromYmd(2024, 1, 1),
        .to = Date.fromYmd(2024, 6, 1),
    };
    try std.testing.expectApproxEqAbs(@as(f64, 0.15), annualized(pr).?, 0.001);
    try std.testing.expect(annualized(null) == null);
}

test "blendReturns both null" {
    const a = ReturnsByPeriod{};
    const b = ReturnsByPeriod{};
    const result = blendReturns(a, 0.5, b, 0.5);
    try std.testing.expect(result.one_year == null);
    try std.testing.expect(result.five_year == null);
}

test "conservativeWeightedReturn single position single period" {
    const positions = [_]PositionReturn{
        .{ .symbol = "SPY", .weight = 1.0, .returns = .{
            .five_year = makePR(0.80, 0.125),
        } },
    };
    // Only 5Y available → MIN is just 0.125
    const result = conservativeWeightedReturn(&positions, null, true);
    try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
}

test "buildComparison with week returns" {
    // Week returns now flow through `TrailingReturns.week` rather
    // than separate parameters — `performance.trailingReturns`
    // populates the field automatically.
    const stock_tr = TrailingReturns{
        .one_year = makePR(0.20, 0.20),
        .week = -0.01,
    };
    const bond_tr = TrailingReturns{
        .one_year = makePR(0.03, 0.03),
        .week = 0.005,
    };
    const positions = [_]PositionReturn{};
    const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions);

    // Week returns should be set
    try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.005), result.bond_returns.week.?, 0.0001);
    // Benchmark week: 0.80 * -0.01 + 0.20 * 0.005 = -0.007
    try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001);
}

test "portfolioWeightedReturns all periods populated" {
    const positions = [_]PositionReturn{
        .{ .symbol = "VTI", .weight = 0.50, .returns = .{
            .one_year = makePR(0.20, 0.20),
            .three_year = makePR(0.50, 0.15),
            .five_year = makePR(0.80, 0.13),
            .ten_year = makePR(2.0, 0.12),
        } },
        .{ .symbol = "BND", .weight = 0.50, .returns = .{
            .one_year = makePR(0.04, 0.04),
            .three_year = makePR(0.10, 0.03),
            .five_year = makePR(0.15, 0.03),
            .ten_year = makePR(0.30, 0.03),
        } },
    };
    const r = portfolioWeightedReturns(&positions);
    // 10Y: (0.50 * 0.12 + 0.50 * 0.03) / 1.0 = 0.075
    try std.testing.expectApproxEqAbs(@as(f64, 0.075), r.ten_year.?, 0.001);
    // 5Y: (0.50 * 0.13 + 0.50 * 0.03) / 1.0 = 0.08
    try std.testing.expectApproxEqAbs(@as(f64, 0.08), r.five_year.?, 0.001);
}

fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation {
    return .{
        .symbol = symbol,
        .display_symbol = symbol,
        .shares = 10,
        .avg_cost = 100,
        .current_price = mv / 10,
        .market_value = mv,
        .cost_basis = 1000,
        .weight = weight,
        .unrealized_gain_loss = mv - 1000,
        .unrealized_return = 0.0,
    };
}

test "deriveAllocationSplit basic stock/bond split via sector" {
    // BND has Debt sector → Fixed Income bucket. SPY/AAPL have
    // GICS sectors → Equity bucket. Cash/CDs add to cash_pct.
    const allocs = [_]Allocation{
        makeAlloc("SPY", 700_000, 0.70),
        makeAlloc("AAPL", 100_000, 0.10),
        makeAlloc("BND", 150_000, 0.15),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "SPY", .sector = "Financial Services" },
        .{ .symbol = "AAPL", .sector = "Technology" },
        .{ .symbol = "BND", .sector = "Debt / Corporate" },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);

    // Bonds: BND $150K → 15%
    try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.bond_pct, 0.01);
    // Cash: $40K + $10K = $50K → 5%
    try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.cash_pct, 0.01);
    // Stock: SPY $700K + AAPL $100K = $800K → 80%
    try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
}

test "deriveAllocationSplit with unclassified positions" {
    const allocs = [_]Allocation{
        makeAlloc("SPY", 600_000, 0.60),
        makeAlloc("MYSTERY", 100_000, 0.10),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "SPY", .sector = "Financial Services" },
        // MYSTERY has no classification entry
    };
    const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);

    // Cash: $50K + $50K = $100K → 12.5%
    try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.cash_pct, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
    // Unclassified: MYSTERY $100K
    try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
    // Stock: SPY $600K → 75%
    try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
}

test "deriveAllocationSplit empty portfolio" {
    const allocs = [_]Allocation{};
    const classes = [_]ClassificationEntry{};
    const result = deriveAllocationSplit(&allocs, &classes, 0, 0, 0);

    // Default fallback for blending math
    try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.25), result.bond_pct, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.cash_pct, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.other_pct, 0.01);
}

test "deriveAllocationSplit no metadata" {
    const allocs = [_]Allocation{
        makeAlloc("SPY", 500_000, 0.50),
        makeAlloc("BND", 300_000, 0.30),
    };
    const classes = [_]ClassificationEntry{}; // no metadata at all
    const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000);

    // Cash: $200K → 20%
    try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.cash_pct, 0.01);
    // Everything except cash is unclassified
    try std.testing.expectApproxEqAbs(@as(f64, 800_000), result.unclassified_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.01);
}

test "deriveAllocationSplit: pure-debt fund with asset_class Fund maps via sector" {
    // VBTLX shape: asset_class is "Fund" (not "Bonds"), but every
    // sector entry is a Debt / * variant. Should land entirely
    // in bond_pct via bucketSector(sector), not in unclassified.
    const allocs = [_]Allocation{
        makeAlloc("VBTLX", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "VBTLX", .asset_class = "Fund", .sector = "Debt / Corporate", .pct = 60.0 },
        .{ .symbol = "VBTLX", .asset_class = "Fund", .sector = "Debt / US Treasury", .pct = 30.0 },
        .{ .symbol = "VBTLX", .asset_class = "Fund", .sector = "Debt / Municipal", .pct = 10.0 },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.bond_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.cash_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.bond_value, 1.0);
}

test "deriveAllocationSplit: pure-equity fund maps via sector" {
    // SCHD shape: 99.7% Equity / Corporate + 0.19% short-term
    // investment vehicle. Stock_pct ≈ 0.997, cash_pct ≈ 0.0019.
    const allocs = [_]Allocation{
        makeAlloc("SCHD", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "SCHD", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 99.70 },
        .{ .symbol = "SCHD", .asset_class = "Fund", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 0.19 },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    try std.testing.expectApproxEqAbs(@as(f64, 0.997), result.stock_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0019), result.cash_pct, 0.0005);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.001);
}

test "deriveAllocationSplit: multi-asset fund splits across buckets" {
    // FAGIX-shape: ~48% Debt + ~22% Equity + others. Should split
    // across stock_pct, bond_pct, cash_pct rather than landing
    // entirely in one bucket.
    const allocs = [_]Allocation{
        makeAlloc("FAGIX", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Debt / Corporate", .pct = 47.69 },
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 22.49 },
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 13.37 },
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Loan / Corporate", .pct = 9.99 },
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Equity Preferred / Corporate", .pct = 3.59 },
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Equity / Registered Fund", .pct = 2.38 },
        .{ .symbol = "FAGIX", .asset_class = "Fund", .sector = "Asset-Backed CBO/CDO / Corporate", .pct = 0.32 },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    // Equity: 22.49 + 3.59 + 2.38 = 28.46%
    try std.testing.expectApproxEqAbs(@as(f64, 0.2846), result.stock_pct, 0.001);
    // Fixed Income: 47.69 + 9.99 + 0.32 = 58.00%
    try std.testing.expectApproxEqAbs(@as(f64, 0.5800), result.bond_pct, 0.001);
    // Cash: 13.37%
    try std.testing.expectApproxEqAbs(@as(f64, 0.1337), result.cash_pct, 0.001);
}

test "deriveAllocationSplit: PTY-shape leveraged fund honestly sums negative repo" {
    // PTY uses ~30% repo leverage. The negative pct flows
    // through honestly into the Cash bucket (Repurchase
    // Agreement → Cash); the long sleeves stay positive.
    const allocs = [_]Allocation{
        makeAlloc("PTY", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "PTY", .asset_class = "Fund", .sector = "Debt / Corporate", .pct = 41.65 },
        .{ .symbol = "PTY", .asset_class = "Fund", .sector = "Loan / Corporate", .pct = 40.05 },
        .{ .symbol = "PTY", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 5.78 },
        .{ .symbol = "PTY", .asset_class = "Fund", .sector = "Repurchase Agreement / Other", .pct = -29.72 },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    // Bond bucket: 41.65 + 40.05 = 81.70%
    try std.testing.expectApproxEqAbs(@as(f64, 0.8170), result.bond_pct, 0.001);
    // Stock bucket: 5.78%
    try std.testing.expectApproxEqAbs(@as(f64, 0.0578), result.stock_pct, 0.001);
    // Cash bucket: -29.72% (honest, negative).
    try std.testing.expectApproxEqAbs(@as(f64, -0.2972), result.cash_pct, 0.001);
}

test "deriveAllocationSplit: derivatives go into Other (excluded from header math)" {
    const allocs = [_]Allocation{
        makeAlloc("FOO", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "FOO", .asset_class = "Fund", .sector = "Equity / Corporate", .pct = 90.0 },
        .{ .symbol = "FOO", .asset_class = "Fund", .sector = "Derivative / Corporate", .pct = 10.0 },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    try std.testing.expectApproxEqAbs(@as(f64, 0.90), result.stock_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.other_pct, 0.001);
    // Bonds and cash unaffected.
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.cash_pct, 0.001);
}

test "deriveAllocationSplit: legacy entry with asset_class only (no sector) buckets via fallback" {
    // Hand-written `metadata.srf` entries pre-EDGAR-decomposition
    // sometimes have asset_class but no sector. The fallback
    // path through `bucketAssetClass` should bucket these
    // correctly rather than dumping them in Other.
    const allocs = [_]Allocation{
        makeAlloc("LEGACY", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "LEGACY", .asset_class = "US Large Cap" },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.stock_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.other_pct, 0.001);
}

test "deriveAllocationSplit: legacy asset_class::Bonds (no sector) maps to Fixed Income" {
    const allocs = [_]Allocation{
        makeAlloc("LEGACY", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "LEGACY", .asset_class = "Bonds" },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.bond_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.other_pct, 0.001);
}

test "deriveAllocationSplit: entry with neither sector nor asset_class lands in Other" {
    // Genuinely unclassifiable: classification entry exists for
    // the symbol but has neither a sector nor an asset_class.
    // No fallback path is possible; goes to Other.
    const allocs = [_]Allocation{
        makeAlloc("BARE", 100_000, 1.0),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "BARE", .geo = "US" }, // no sector, no asset_class
    };
    const result = deriveAllocationSplit(&allocs, &classes, 100_000, 0, 0);

    try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.other_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.001);
    try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_pct, 0.001);
}

test "deriveAllocationSplit stock and bond pct sum with unclassified" {
    const allocs = [_]Allocation{
        makeAlloc("SPY", 500_000, 0.50),
        makeAlloc("BND", 200_000, 0.20),
        makeAlloc("NEW", 50_000, 0.05),
    };
    const classes = [_]ClassificationEntry{
        .{ .symbol = "SPY", .sector = "Financial Services" },
        .{ .symbol = "BND", .sector = "Debt / Corporate" },
    };
    const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 200_000, 50_000);

    // stock + bond + cash + other + unclassified should account for everything
    const unclass_pct = result.unclassified_value / 1_000_000;
    try std.testing.expectApproxEqAbs(
        @as(f64, 1.0),
        result.stock_pct + result.bond_pct + result.cash_pct + result.other_pct + unclass_pct,
        0.01,
    );
}
