//! Look-through exposure aggregation.
//!
//! Answers "how much of underlying symbol X do I really hold?" by
//! unifying two sources:
//!
//!   1. **Direct** — a position whose ticker *is* X.
//!   2. **Look-through** — X held inside the top holdings of ETFs the
//!      portfolio owns. A fund worth $V that holds X at weight w
//!      contributes `V * w` dollars of X exposure.
//!
//! `analyze` owns the whole transform: it resolves each holding's
//! underlying ticker (NPORT ticker, else CUSIP via a caller-supplied
//! map), flags fund-of-funds blind spots, and aggregates. It is pure —
//! no I/O, no `DataService`. The command fetches ETF profiles and the
//! CUSIP map (the I/O) and hands the raw data here, which keeps this
//! load-bearing logic unit-testable with literal fixtures.

const std = @import("std");
const Holding = @import("../models/etf_profile.zig").Holding;

/// A portfolio fund whose holdings are looked through, only when it is
/// only a fund — broad equity ETFs with a small cash-sweep "Fund"
/// holding don't count.
pub const nested_fof_threshold: f64 = 0.20; // 20%

/// A directly-held portfolio position, reduced to what the calc needs.
pub const DirectPosition = struct {
    symbol: []const u8,
    /// Market value of the position.
    value: f64,
};

/// A fund held in the portfolio plus its (unresolved) NPORT-P top
/// holdings. `analyze` resolves each holding's underlying ticker.
pub const FundInput = struct {
    /// The fund's own ticker (the symbol held directly).
    fund: []const u8,
    /// Market value of the fund position in the portfolio.
    value: f64,
    holdings: []const Holding,
};

/// One fund's contribution to the target's look-through exposure.
pub const FundContribution = struct {
    fund: []const u8,
    /// Dollars of the target reached through this fund
    /// (`fund.value * Σ matching holding weights`).
    value: f64,
    /// The target's combined weight *within this fund* (decimal). Two
    /// share classes of the same underlying in one fund sum here.
    weight_in_fund: f64,
};

/// Aggregated exposure to a single underlying symbol. String fields
/// borrow from the `analyze` inputs (which must outlive the result);
/// `contributions` and `fund_of_funds` are allocated.
pub const ExposureResult = struct {
    symbol: []const u8,
    /// Portfolio total value — the denominator for every weight.
    total_value: f64,
    /// Dollars of the target held directly.
    direct_value: f64,
    /// Dollars of the target reached via ETF look-through.
    lookthrough_value: f64,
    /// Per-fund contributions, sorted descending by value. Only funds
    /// with nonzero exposure to the target appear.
    contributions: []const FundContribution,
    /// Count of fund holdings that could not be identified (no ticker,
    /// no resolvable CUSIP) across all scanned funds. Bounds how much
    /// exposure the look-through might be undercounting.
    unresolved_holdings: usize,
    /// Total market value sitting in funds-of-funds whose underlying
    /// funds the single-level look-through does not expand (e.g.
    /// target-date funds holding a total-market index fund).
    nested_fund_value: f64 = 0,
    /// Tickers of those funds-of-funds (e.g. `{ "FUNDA", "FUNDB" }`);
    /// empty when none detected. Names borrow from the inputs; the
    /// outer slice is allocated.
    fund_of_funds: []const []const u8 = &.{},

    /// Total dollars of the target (direct + look-through).
    pub fn totalValue(self: ExposureResult) f64 {
        return self.direct_value + self.lookthrough_value;
    }

    /// Total exposure as a fraction of the portfolio (0..1). Zero when
    /// the portfolio has no value.
    pub fn totalWeight(self: ExposureResult) f64 {
        return self.fractionOf(self.totalValue());
    }

    pub fn directWeight(self: ExposureResult) f64 {
        return self.fractionOf(self.direct_value);
    }

    pub fn lookthroughWeight(self: ExposureResult) f64 {
        return self.fractionOf(self.lookthrough_value);
    }

    /// `value` as a fraction of the portfolio total. Safe when total
    /// is zero (returns 0 rather than dividing).
    pub fn fractionOf(self: ExposureResult, value: f64) f64 {
        if (self.total_value <= 0) return 0;
        return value / self.total_value;
    }

    pub fn deinit(self: *ExposureResult, allocator: std.mem.Allocator) void {
        allocator.free(self.contributions);
        if (self.fund_of_funds.len > 0) allocator.free(self.fund_of_funds);
    }
};

/// Resolve, flag, and aggregate exposure to `target` from directly-held
/// positions plus the look-through holdings of `funds`.
///
/// Each holding's underlying ticker is `holding.symbol` (the NPORT-P
/// ticker, rarely present) or, failing that, `cusip_to_ticker` applied
/// to `holding.cusip`. Holdings that resolve to neither are counted in
/// `unresolved_holdings`. Holdings that are themselves funds are flagged
/// as a look-through blind spot (`nested_fund_value` / `fund_of_funds`)
/// rather than expanded — single level only.
///
/// `target` matching is exact and case-sensitive — the caller uppercases
/// both the query and (where applicable) the resolved tickers.
///
/// `contributions` (sorted descending by dollar value) and
/// `fund_of_funds` are allocated from `allocator`; all string fields
/// borrow from `directs`, `funds`, and `cusip_to_ticker`. The caller
/// owns the result (`deinit`).
pub fn analyze(
    allocator: std.mem.Allocator,
    target: []const u8,
    total_value: f64,
    directs: []const DirectPosition,
    funds: []const FundInput,
    cusip_to_ticker: *const std.StringHashMap([]const u8),
) !ExposureResult {
    var direct_value: f64 = 0;
    for (directs) |d| {
        if (std.mem.eql(u8, d.symbol, target)) direct_value += d.value;
    }

    var contribs: std.ArrayList(FundContribution) = .empty;
    errdefer contribs.deinit(allocator);
    var fof_names: std.ArrayList([]const u8) = .empty;
    errdefer fof_names.deinit(allocator);

    var lookthrough_value: f64 = 0;
    var unresolved: usize = 0;
    var nested_value: f64 = 0;

    for (funds) |f| {
        var fund_value: f64 = 0;
        var fund_weight: f64 = 0;
        var fund_nested_value: f64 = 0;
        var fund_nested_weight: f64 = 0;
        for (f.holdings) |h| {
            // Fund-of-funds blind spot: a holding that is itself a fund
            // is not expanded (no recursion). Tracked for a footnote.
            if (isNestedFund(h.name)) {
                fund_nested_value += f.value * h.weight;
                fund_nested_weight += h.weight;
            }
            // Resolve the holding's underlying ticker: the NPORT-P
            // ticker if present, else the CUSIP via the resolution map.
            const ticker = h.symbol orelse if (h.cusip) |c| cusip_to_ticker.get(c) else null;
            if (ticker) |t| {
                if (std.mem.eql(u8, t, target)) {
                    fund_value += f.value * h.weight;
                    fund_weight += h.weight;
                }
            } else {
                unresolved += 1;
            }
        }
        if (fund_value > 0) {
            try contribs.append(allocator, .{
                .fund = f.fund,
                .value = fund_value,
                .weight_in_fund = fund_weight,
            });
            lookthrough_value += fund_value;
        }
        // Only a substantial nested-fund share marks a fund-of-funds; a
        // broad ETF with a small cash-sweep holding does not qualify.
        if (fund_nested_weight >= nested_fof_threshold) {
            nested_value += fund_nested_value;
            try fof_names.append(allocator, f.fund);
        }
    }

    const slice = try contribs.toOwnedSlice(allocator);
    errdefer allocator.free(slice);
    std.sort.pdq(FundContribution, slice, {}, struct {
        fn lessThan(_: void, a: FundContribution, b: FundContribution) bool {
            return a.value > b.value; // descending
        }
    }.lessThan);

    const fof: []const []const u8 = if (fof_names.items.len > 0)
        try fof_names.toOwnedSlice(allocator)
    else blk: {
        fof_names.deinit(allocator);
        break :blk &.{};
    };

    return .{
        .symbol = target,
        .total_value = total_value,
        .direct_value = direct_value,
        .lookthrough_value = lookthrough_value,
        .contributions = slice,
        .unresolved_holdings = unresolved,
        .nested_fund_value = nested_value,
        .fund_of_funds = fof,
    };
}

/// Heuristic: does this holding name denote a fund (ETF or mutual
/// fund) rather than an operating company? Used to flag fund-of-funds
/// holdings the single-level look-through doesn't expand — e.g. a
/// target-date fund's underlying total-market index fund.
///
/// Cash-sweep / money-market / central vehicles match "fund" by name
/// but are not equity look-through blind spots, so they're excluded.
pub fn isNestedFund(name: []const u8) bool {
    const cash_markers = [_][]const u8{
        "money market", "liquid", "prime fund",
        "sweep",        "cash",   "government fund",
        "central fund",
    };
    for (cash_markers) |m| {
        if (std.ascii.indexOfIgnoreCase(name, m) != null) return false;
    }
    return std.ascii.indexOfIgnoreCase(name, "fund") != null or
        std.ascii.indexOfIgnoreCase(name, " etf") != null;
}

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

/// An empty CUSIP→ticker map for tests that resolve purely by NPORT
/// ticker. Caller deinits.
fn emptyMap() std.StringHashMap([]const u8) {
    return std.StringHashMap([]const u8).init(std.testing.allocator);
}

test "analyze: direct only" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    const directs = [_]DirectPosition{
        .{ .symbol = "AAPL", .value = 10_000 },
        .{ .symbol = "MSFT", .value = 5_000 },
    };
    var result = try analyze(allocator, "AAPL", 100_000, &directs, &.{}, &map);
    defer result.deinit(allocator);

    try std.testing.expectApproxEqAbs(@as(f64, 10_000), result.direct_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0), result.lookthrough_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.totalWeight(), 0.0001);
    try std.testing.expectEqual(@as(usize, 0), result.contributions.len);
}

test "analyze: look-through via a single fund (NPORT ticker)" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    // QQQ worth $30,000, holds AAPL at 10%.
    const holdings = [_]Holding{
        .{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.10 },
        .{ .name = "Microsoft Corp", .symbol = "MSFT", .weight = 0.08 },
    };
    const funds = [_]FundInput{.{ .fund = "QQQ", .value = 30_000, .holdings = &holdings }};
    var result = try analyze(allocator, "AAPL", 100_000, &.{}, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectApproxEqAbs(@as(f64, 3_000), result.lookthrough_value, 0.01); // 30000 * 0.10
    try std.testing.expectEqual(@as(usize, 1), result.contributions.len);
    try std.testing.expectEqualStrings("QQQ", result.contributions[0].fund);
    try std.testing.expectApproxEqAbs(@as(f64, 0.10), result.contributions[0].weight_in_fund, 0.0001);
}

test "analyze: resolves a null-ticker holding via the CUSIP map" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    try map.put("111111111", "AMZN");
    // SPY-style holding: no NPORT ticker, CUSIP only.
    const holdings = [_]Holding{
        .{ .name = "Amazon.com Inc", .cusip = "111111111", .weight = 0.05 },
    };
    const funds = [_]FundInput{.{ .fund = "SPY", .value = 1_000_000, .holdings = &holdings }};
    var result = try analyze(allocator, "AMZN", 5_000_000, &.{}, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.lookthrough_value, 0.01); // 1,000,000 * 0.05
    try std.testing.expectEqual(@as(usize, 1), result.contributions.len);
    try std.testing.expectEqualStrings("SPY", result.contributions[0].fund);
    try std.testing.expectEqual(@as(usize, 0), result.unresolved_holdings);
}

test "analyze: unifies direct + indirect for the same symbol" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    const directs = [_]DirectPosition{.{ .symbol = "AAPL", .value = 12_500 }};
    const qqq = [_]Holding{.{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.10 }};
    const xlk = [_]Holding{.{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.20 }};
    const funds = [_]FundInput{
        .{ .fund = "QQQ", .value = 30_000, .holdings = &qqq }, // 3,000
        .{ .fund = "XLK", .value = 5_000, .holdings = &xlk }, // 1,000
    };
    var result = try analyze(allocator, "AAPL", 100_000, &directs, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectApproxEqAbs(@as(f64, 12_500), result.direct_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 4_000), result.lookthrough_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.165), result.totalWeight(), 0.0001);
    // Sorted descending: QQQ (3,000) before XLK (1,000).
    try std.testing.expectEqual(@as(usize, 2), result.contributions.len);
    try std.testing.expectEqualStrings("QQQ", result.contributions[0].fund);
    try std.testing.expectEqualStrings("XLK", result.contributions[1].fund);
}

test "analyze: unresolvable holdings are skipped and counted" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    const holdings = [_]Holding{
        .{ .name = "Apple Inc", .symbol = "AAPL", .weight = 0.05 },
        .{ .name = "Some Foreign Bond", .weight = 0.30 }, // no symbol, no cusip
        .{ .name = "Another Foreign Name", .cusip = "999999999", .weight = 0.20 }, // cusip not in map
    };
    const funds = [_]FundInput{.{ .fund = "AGG", .value = 10_000, .holdings = &holdings }};
    var result = try analyze(allocator, "AAPL", 100_000, &.{}, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectApproxEqAbs(@as(f64, 500), result.lookthrough_value, 0.01); // 10000 * 0.05
    try std.testing.expectEqual(@as(usize, 2), result.unresolved_holdings);
}

test "analyze: no exposure yields empty result" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    const directs = [_]DirectPosition{.{ .symbol = "MSFT", .value = 5_000 }};
    const holdings = [_]Holding{.{ .name = "Nvidia Corp", .symbol = "NVDA", .weight = 0.40 }};
    const funds = [_]FundInput{.{ .fund = "SMH", .value = 8_000, .holdings = &holdings }};
    var result = try analyze(allocator, "AAPL", 100_000, &directs, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectApproxEqAbs(@as(f64, 0), result.totalValue(), 0.01);
    try std.testing.expectEqual(@as(usize, 0), result.contributions.len);
}

test "analyze: two share classes of the target in one fund sum" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    const holdings = [_]Holding{
        .{ .name = "Alphabet Inc", .symbol = "GOOGL", .weight = 0.03 },
        .{ .name = "Alphabet Inc", .symbol = "GOOGL", .weight = 0.02 },
    };
    const funds = [_]FundInput{.{ .fund = "VOO", .value = 50_000, .holdings = &holdings }};
    var result = try analyze(allocator, "GOOGL", 200_000, &.{}, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), result.contributions.len);
    try std.testing.expectApproxEqAbs(@as(f64, 0.05), result.contributions[0].weight_in_fund, 0.0001);
    try std.testing.expectApproxEqAbs(@as(f64, 2_500), result.lookthrough_value, 0.01); // 50000 * 0.05
}

test "analyze: zero total_value does not divide by zero" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    const directs = [_]DirectPosition{.{ .symbol = "AAPL", .value = 0 }};
    var result = try analyze(allocator, "AAPL", 0, &directs, &.{}, &map);
    defer result.deinit(allocator);
    try std.testing.expectApproxEqAbs(@as(f64, 0), result.totalWeight(), 0.0001);
}

test "analyze: flags a fund-of-funds, not a broad ETF with cash" {
    const allocator = std.testing.allocator;
    var map = emptyMap();
    defer map.deinit();
    // Target-date-style wrapper: ~99% in underlying index funds.
    const wrapper = [_]Holding{
        .{ .name = "Sample Total Stock Market Index Fund", .weight = 0.40 },
        .{ .name = "Sample Total Bond Market Index Fund", .weight = 0.35 },
        .{ .name = "Sample Total International Index Fund", .weight = 0.24 },
        .{ .name = "Sample Market Liquidity Fund", .weight = 0.01 }, // cash, excluded
    };
    // Broad fund with one small cash-sweep "Fund" — NOT a fund-of-funds.
    const broad = [_]Holding{
        .{ .name = "Sample Operating Co", .symbol = "FOO", .weight = 0.90 },
        .{ .name = "Sample Private Government Fund", .weight = 0.08 }, // cash, excluded
    };
    const funds = [_]FundInput{
        .{ .fund = "FUNDA", .value = 100_000, .holdings = &wrapper },
        .{ .fund = "FUNDB", .value = 50_000, .holdings = &broad },
    };
    var result = try analyze(allocator, "AAPL", 1_000_000, &.{}, &funds, &map);
    defer result.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), result.fund_of_funds.len);
    try std.testing.expectEqualStrings("FUNDA", result.fund_of_funds[0]);
    // nested value = 100k * (0.40 + 0.35 + 0.24) = 99k (liquidity excluded).
    try std.testing.expectApproxEqAbs(@as(f64, 99_000), result.nested_fund_value, 1.0);
}

test "isNestedFund: flags index/ETF funds, skips operating cos and cash" {
    // Nested funds (the look-through blind spot).
    try std.testing.expect(isNestedFund("Sample Total Stock Market Index Fund"));
    try std.testing.expect(isNestedFund("Sample Total Bond Market Index Fund"));
    try std.testing.expect(isNestedFund("Sample Core S&P 500 ETF"));
    // Operating companies are not funds.
    try std.testing.expect(!isNestedFund("Sample Operating Co"));
    try std.testing.expect(!isNestedFund("Another Operating Co Inc"));
    // Cash-sweep / money-market / central vehicles are funds by name
    // but excluded — one case per marker.
    try std.testing.expect(!isNestedFund("Sample Market Liquidity Fund"));
    try std.testing.expect(!isNestedFund("Sample Private Prime Fund"));
    try std.testing.expect(!isNestedFund("Sample Private Government Fund"));
    try std.testing.expect(!isNestedFund("Sample Private Credit Central Fund LLC"));
    try std.testing.expect(!isNestedFund("Sample Liquid Assets Portfolio"));
    try std.testing.expect(!isNestedFund("Sample Money Market Fund"));
}
