/// Portfolio analysis engine.
///
/// Takes portfolio allocations (with market values) and classification metadata,
/// produces breakdowns by asset class, sector, geographic region, account, and tax type.
const std = @import("std");
const srf = @import("srf");
const Allocation = @import("valuation.zig").Allocation;
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");

/// A single slice of a breakdown (e.g., "Technology" -> 25.3%)
pub const BreakdownItem = struct {
    label: []const u8,
    value: f64, // dollar amount
    weight: f64, // fraction of total (0.0 - 1.0)
};

/// Tax type classification for accounts.
pub const TaxType = enum {
    taxable,
    roth,
    traditional,
    hsa,

    pub fn label(self: TaxType) []const u8 {
        return switch (self) {
            .taxable => "Taxable",
            .roth => "Roth (Post-Tax)",
            .traditional => "Traditional (Pre-Tax)",
            .hsa => "HSA (Triple Tax-Free)",
        };
    }
};

/// Account tax type classification entry, parsed from accounts.srf.
pub const AccountTaxEntry = struct {
    account: []const u8,
    tax_type: TaxType,
    institution: ?[]const u8 = null,
    account_number: ?[]const u8 = null,
    update_cadence: UpdateCadence = .weekly,
    /// When true, raw cash-balance changes (`cash_delta` in the
    /// contributions diff) on this account roll up into the
    /// attribution total as real contributions.
    ///
    /// Defaults to false because most cash accounts generate
    /// `cash_delta` entries from internal movement — interest posting,
    /// dividend credit, CD coupon, settlement sweeps — that would
    /// inflate the attribution number if counted. Set to true only
    /// for accounts whose cash movement is dominated by external
    /// contributions (payroll ESPP accrual, direct 401k cash
    /// deposits). See TODO.md for the design history.
    cash_is_contribution: bool = false,
    /// When true, marks the account as a direct-indexing proxy
    /// (lots track a benchmark with tracking-error drift rather
    /// than holding the benchmark directly). Two behaviors:
    ///
    ///   1. Contributions (`zfin contributions` / `zfin compare`
    ///      attribution): the edit-detection residual tolerance is
    ///      loosened from 0.01% (noise floor) to 1% — tracking-
    ///      error share reconciliation no longer lands in
    ///      `rollup_delta` / `drip_negative` and the attribution
    ///      total stays clean.
    ///
    ///   2. Audit (`zfin audit` ratio-suggestions section): lots
    ///      with `price_ratio == 1.0` in this account get a
    ///      suggested ratio to bridge the brokerage vs. portfolio
    ///      value gap. Default audit behavior skips ratio == 1.0
    ///      lots since there's nothing to adjust; direct-indexing
    ///      accounts opt out of that skip.
    ///
    /// Not a general "ignore drift" flag — use only for accounts
    /// whose underlying lots explicitly track a benchmark (e.g. a
    /// basket of 500 individual stocks tracked as SPY via `ticker::`
    /// alias).
    direct_indexing: bool = false,
    /// Optional umbrella-insurance shielding override. When null,
    /// the umbrella-exposure calculation defaults to "tax_type !=
    /// taxable means shielded" (a rough proxy for retirement-account
    /// status). Set explicitly when the default is wrong:
    ///
    ///   - `shielded:bool:false` for pre-tax accounts that are NOT
    ///     ERISA-protected (e.g. deferred-comp plans like Fidelity
    ///     DCP, non-qualified annuities) — tax_type is `traditional`
    ///     so they default to shielded, but they're not protected
    ///     against civil judgments.
    ///   - `shielded:bool:true` to mark a taxable account as
    ///     shielded (rare; e.g. some asset-protection trusts).
    ///
    /// IRA state-by-state protection is not modeled. Users in
    /// states with weak IRA protection should set
    /// `shielded:bool:false` on their IRA accounts to get a
    /// correct umbrella-exposure number.
    shielded: ?bool = null,
};

/// Update cadence for manual account maintenance. Parsed from accounts.srf.
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
pub const UpdateCadence = enum {
    weekly,
    monthly,
    quarterly,
    none,

    /// Number of calendar days before an account is considered overdue.
    pub fn thresholdDays(self: UpdateCadence) ?u32 {
        return switch (self) {
            .weekly => 7,
            .monthly => 30,
            .quarterly => 90,
            .none => null,
        };
    }

    pub fn label(self: UpdateCadence) []const u8 {
        return switch (self) {
            .weekly => "weekly",
            .monthly => "monthly",
            .quarterly => "quarterly",
            .none => "none",
        };
    }
};

/// Parsed account metadata.
pub const AccountMap = struct {
    entries: []AccountTaxEntry,
    allocator: std.mem.Allocator,

    pub fn deinit(self: *AccountMap) void {
        for (self.entries) |e| {
            self.allocator.free(e.account);
            if (e.institution) |s| self.allocator.free(s);
            if (e.account_number) |s| self.allocator.free(s);
        }
        self.allocator.free(self.entries);
    }

    /// Look up the tax type label for a given account name.
    pub fn taxTypeFor(self: AccountMap, account: []const u8) []const u8 {
        for (self.entries) |e| {
            if (std.mem.eql(u8, e.account, account)) {
                return e.tax_type.label();
            }
        }
        return "Unknown";
    }

    /// Find the portfolio account name for a given institution + account number.
    pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
        for (self.entries) |e| {
            if (e.institution) |inst| {
                if (e.account_number) |num| {
                    if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
                        return e.account;
                }
            }
        }
        return null;
    }

    /// Return all entries matching a given institution.
    pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
        var count: usize = 0;
        for (self.entries) |e| {
            if (e.institution) |inst| {
                if (std.mem.eql(u8, inst, institution)) count += 1;
            }
        }
        if (count == 0) return &.{};
        return self.entries;
    }

    /// Is cash-balance movement on `account` treated as a real
    /// contribution (vs. internal noise) for the attribution total?
    /// Defaults to false when the account isn't in the map.
    pub fn cashIsContribution(self: AccountMap, account: []const u8) bool {
        for (self.entries) |e| {
            if (std.mem.eql(u8, e.account, account)) {
                return e.cash_is_contribution;
            }
        }
        return false;
    }

    /// Is `account` flagged as a direct-indexing proxy? See
    /// `AccountTaxEntry.direct_indexing` for the two behaviors this
    /// drives. Defaults to false when the account isn't in the map.
    pub fn isDirectIndexing(self: AccountMap, account: []const u8) bool {
        for (self.entries) |e| {
            if (std.mem.eql(u8, e.account, account)) {
                return e.direct_indexing;
            }
        }
        return false;
    }
};

/// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
    var entries = std.ArrayList(AccountTaxEntry).empty;
    errdefer {
        for (entries.items) |e| {
            allocator.free(e.account);
            if (e.institution) |s| allocator.free(s);
            if (e.account_number) |s| allocator.free(s);
        }
        entries.deinit(allocator);
    }

    var reader = std.Io.Reader.fixed(data);
    var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData;
    defer it.deinit();

    while (try it.next()) |fields| {
        const entry = fields.to(AccountTaxEntry, .{}) catch continue;
        try entries.append(allocator, .{
            .account = try allocator.dupe(u8, entry.account),
            .tax_type = entry.tax_type,
            .institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
            .account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
            .update_cadence = entry.update_cadence,
            .cash_is_contribution = entry.cash_is_contribution,
            .direct_indexing = entry.direct_indexing,
            .shielded = entry.shielded,
        });
    }

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

/// Complete portfolio analysis result.
pub const AnalysisResult = struct {
    /// Coarse 4-bucket breakdown: Equity / Fixed Income / Cash / Other.
    /// Built by mapping each fine-grained sector through `bucketSector`
    /// before aggregation. The right field for portfolio-level
    /// debt-to-equity analysis.
    asset_category: []BreakdownItem,
    /// Breakdown by sector bucket (Technology, US Healthcare ETF,
    /// US Large Cap, etc.). Aggregates by `entry.bucket` —
    /// pre-filled by parseClassificationFile via `deriveBucket`,
    /// or curated by the user. Replaces the historical separate
    /// "Asset Class" + "Sector" breakdowns: the bucket is a
    /// single semantically-meaningful label that combines what
    /// each was trying to express.
    sector: []BreakdownItem,
    /// Breakdown by geographic region (US, International, etc.)
    geo: []BreakdownItem,
    /// Breakdown by account name
    account: []BreakdownItem,
    /// Breakdown by tax type (Taxable, Roth, Traditional, HSA)
    tax_type: []BreakdownItem,
    /// Positions not covered by classification metadata
    unclassified: []const []const u8,
    /// Total portfolio value used as denominator
    total_value: f64,

    pub fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void {
        allocator.free(self.asset_category);
        allocator.free(self.sector);
        allocator.free(self.geo);
        allocator.free(self.account);
        allocator.free(self.tax_type);
        allocator.free(self.unclassified);
    }
};

/// One section of an analysis breakdown for renderer-agnostic
/// display. Both the CLI (`commands/analysis.zig`) and the TUI
/// (`tui/analysis_tab.zig`) walk the section list returned by
/// `breakdownSections` to build their output. The section list
/// is the single source of truth for which breakdowns appear and
/// in what order; renderers apply their own indent and styling.
pub const Section = struct {
    items: []const BreakdownItem,
    /// Title with no leading whitespace. Renderers indent.
    title: []const u8,
};

/// Single source of truth for analysis-output breakdown
/// sections. Both the CLI display and the TUI tab call this so
/// adding/reordering a section is a one-place edit. Order is
/// from coarsest (Asset Category, 4 buckets) to finest
/// (per-account / per-tax-type).
pub fn breakdownSections(r: *const AnalysisResult) [5]Section {
    return .{
        .{ .items = r.asset_category, .title = "Asset Category" },
        .{ .items = r.sector, .title = "Sector" },
        .{ .items = r.geo, .title = "Geographic" },
        .{ .items = r.account, .title = "By Account" },
        .{ .items = r.tax_type, .title = "By Tax Type" },
    };
}

// ── Umbrella-insurance exposure ──────────────────────────────

/// Result of computing umbrella-insurance exposure: the portion
/// of the liquid portfolio that's NOT legally shielded against
/// civil judgments / lawsuits, and therefore needs umbrella
/// coverage.
pub const UmbrellaExposure = struct {
    /// Total liquid portfolio value summed from `account_breakdown`.
    /// Equals shielded + exposed.
    total_liquid: f64,
    /// Sum of account values where shielding evaluates to true.
    shielded_value: f64,
    /// Sum of account values where shielding evaluates to false.
    /// This is the approximate umbrella-insurance target.
    exposed_value: f64,
    /// `exposed_value / total_liquid`, or 0 when `total_liquid` is 0.
    exposed_pct: f64,
};

/// Compute umbrella-insurance exposure from the per-account
/// breakdown and the account-tax map.
///
/// Shielding decision per account:
///   - If `entry.shielded` is explicitly set, use that.
///   - Else if `entry.tax_type == .taxable`, NOT shielded.
///   - Else (Traditional / Roth / HSA), shielded by default.
///
/// Accounts not in `account_map` default to NOT shielded
/// (defensive — if we don't know, assume the value is exposed
/// rather than overstate the user's protection).
///
/// Pure data, no allocation. The arithmetic is straightforward
/// summation; the meaningful logic is the per-account
/// shielded-or-not decision.
pub fn umbrellaExposure(
    account_breakdown: []const BreakdownItem,
    account_map: AccountMap,
) UmbrellaExposure {
    var shielded: f64 = 0;
    var exposed: f64 = 0;

    for (account_breakdown) |item| {
        const is_shielded = accountIsShielded(item.label, account_map);
        if (is_shielded) {
            shielded += item.value;
        } else {
            exposed += item.value;
        }
    }

    const total = shielded + exposed;
    const pct = if (total > 0) exposed / total else 0;

    return .{
        .total_liquid = total,
        .shielded_value = shielded,
        .exposed_value = exposed,
        .exposed_pct = pct,
    };
}

/// Look up the shielding decision for one account name.
/// Exposed (returns false) when:
///   - Account is not in the map (defensive default).
///   - Explicit `shielded::false` override.
///   - tax_type is `taxable` and no override.
fn accountIsShielded(account: []const u8, account_map: AccountMap) bool {
    for (account_map.entries) |e| {
        if (!std.mem.eql(u8, e.account, account)) continue;
        if (e.shielded) |explicit| return explicit;
        return e.tax_type != .taxable;
    }
    return false;
}

// ── Sector → asset-category bucket ────────────────────────────

/// The four coarse asset-category buckets. Returned from
/// `bucketSector` as static `[]const u8` literals so callers can
/// use them as stable HashMap keys without duping.
pub const bucket_equity: []const u8 = "Equity";
pub const bucket_fixed_income: []const u8 = "Fixed Income";
pub const bucket_cash: []const u8 = "Cash";
pub const bucket_other: []const u8 = "Other";

/// Map a sector string to one of four coarse asset-category
/// buckets. Handles three input shapes:
///
///   - **NPORT-P fund-decomposition sectors** of the form
///     `"<assetCat> / <issuerCat>"` (e.g. `"Debt / US Treasury"`,
///     `"Equity / Corporate"`, `"Short-Term Investment Vehicle / Registered Fund"`).
///     These come from EDGAR fund-holdings data via `enrich`.
///
///   - **GICS-style stock sector names** (e.g. `"Technology"`,
///     `"Healthcare"`, `"Financial Services"`). These come from
///     Wikidata via `enrich`'s `canonicalizeSector`.
///
///   - **Plain-English asset-class words** (e.g. `"Bonds"`,
///     `"Diversified"`) that hand-written `metadata.srf` files
///     use for legacy entries. `"Bonds"` → Fixed Income;
///     `"Diversified"` → Equity (the word in practice means "S&P
///     500 / total-market index fund holding all sectors", which
///     is overwhelmingly equity).
///
/// Returns one of `bucket_equity`, `bucket_fixed_income`,
/// `bucket_cash`, or `bucket_other`. Anything unrecognized
/// (sentinels like `"TODO"`, empty string, future label
/// changes) falls through to `bucket_other`.
///
/// Note: `Equity Preferred / *` rolls up to Equity, not Fixed
/// Income. Preferreds trade between stocks and bonds; we lean
/// equity to match how most retail asset-allocation views treat
/// them.
pub fn bucketSector(sector: []const u8) []const u8 {
    // NPORT-P shapes: prefix-match on the assetCat half.
    // `startsWith` covers both `Equity / *` and `Equity Preferred / *`.
    //
    // Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.):
    // these bucket as Equity, not Fixed Income, despite their
    // bond-like income shape. The Asset Category breakdown
    // answers "what's exposed to equity drawdowns?" — and
    // dividend funds drop with the market in a 2008-style
    // crash. The income-feels-like-bonds intuition belongs in
    // a separate yield-weighted analysis (see TODO.md
    // "Dividend equity / income-shaped equity"), not in the
    // asset-class taxonomy.
    if (std.mem.startsWith(u8, sector, "Equity")) return bucket_equity;
    if (std.mem.startsWith(u8, sector, "Debt")) return bucket_fixed_income;
    if (std.mem.startsWith(u8, sector, "Loan")) return bucket_fixed_income;
    if (std.mem.startsWith(u8, sector, "Asset-Backed")) return bucket_fixed_income;
    if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return bucket_cash;
    if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return bucket_cash;

    // Plain-English asset-class words (hand-written metadata).
    if (std.mem.eql(u8, sector, "Bonds")) return bucket_fixed_income;
    if (std.mem.eql(u8, sector, "Cash")) return bucket_cash;
    if (std.mem.eql(u8, sector, "Cash & CDs")) return bucket_cash;
    if (std.mem.eql(u8, sector, "Options")) return bucket_other;
    if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other;
    // "Diversified" means "broad equity fund holding all
    // sectors" — S&P 500 ETF, total-market index, etc.
    if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity;

    // GICS stock sector names. Exact match over the canonical 11
    // returned by `Wikidata.canonicalizeSector`. The legacy
    // `"Financials"` (with 's') from old hand-written entries
    // also maps here.
    const gics = [_][]const u8{
        "Technology",
        "Healthcare",
        "Financial Services",
        "Financials",
        "Consumer Cyclical",
        "Consumer Defensive",
        "Energy",
        "Utilities",
        "Real Estate",
        "Industrials",
        "Basic Materials",
        "Communication Services",
    };
    for (gics) |g| if (std.mem.eql(u8, sector, g)) return bucket_equity;

    // Strings containing `/` are NPORT-P shapes that didn't match
    // any prefix above (e.g. "Direct Real Property / Other",
    // "Direct Credit Risk / Other", "Other / Corporate"). Bucket
    // these as Other — they're real-property, credit derivatives,
    // and miscellaneous categories that don't fit the equity /
    // fixed-income / cash trichotomy.
    if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other;

    // Empty string / explicit sentinels → Other. Explicit
    // because the curated-bucket fallback below would otherwise
    // assume any non-empty unknown string is equity.
    if (sector.len == 0) return bucket_other;
    if (std.mem.eql(u8, sector, "TODO")) return bucket_other;
    if (std.mem.eql(u8, sector, "Unknown")) return bucket_other;

    // Word-content checks for composite bucket strings produced by
    // `deriveBucket` (or hand-curated `bucket::` overrides):
    //   "US Bonds", future "International Bonds" / "EM Bonds" → Fixed Income
    //   "US Cash", "Cash & CDs" (handled above) → Cash
    if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) {
        return bucket_fixed_income;
    }
    if (std.mem.endsWith(u8, sector, " Cash") or std.mem.endsWith(u8, sector, " cash")) {
        return bucket_cash;
    }

    // Default for any remaining no-`/` non-cruft string: equity.
    // Catches curated buckets like "US Large Cap", "US Mid Cap",
    // "US Small Cap", "US Dividend Equity", "US Healthcare ETF",
    // "International Developed", "Emerging Markets", and any
    // future user-defined bucket. The convention is: composite
    // buckets describe an equity sleeve unless they explicitly
    // say otherwise (Bonds/Cash/Options/Unclassified handled
    // above).
    return bucket_equity;
}

// ── Sector display granularity ───────────────────────────────

/// Granularity tier for the Sector breakdown display. Two
/// tiers: `coarse` (4 macro buckets — Equity / Fixed Income /
/// Cash / Other) and `fine` (the raw bucket strings the
/// classification layer produced — every "US Large Cap" / "US
/// Bonds" / GICS-sector / etc. row distinct).
///
/// History: this used to be a three-tier enum (coarse / mid /
/// fine). The middle tier collapsed NPORT-P sub-flavors (all
/// Debt / * → "Bonds", all Asset-Backed / * → "Bonds", etc.)
/// while keeping GICS sectors distinct. After the bucket
/// commit, classification rows expose a single curated bucket
/// label per entry — so the NPORT-P-flavor collapse the mid
/// tier did is now done at parse time. Mid and fine ended up
/// nearly identical and mid was dropped.
pub const Granularity = enum {
    /// Four buckets: Equity / Fixed Income / Cash / Other.
    /// Same labels as the Asset Category breakdown.
    coarse,
    /// One row per distinct bucket label — the raw shape of
    /// what `entry.bucket` produces. Default. This is what
    /// the user wants for "what are my actual positions?"
    fine,
};

/// Display-friendly abbreviations for sector labels that don't fit
/// cleanly in narrow columns. Returns the input unchanged when no
/// abbreviation is registered for it; consumers that need a fixed
/// width should also pass the result through `format.truncateToCols`.
///
/// Single source of truth for both the analysis tab's sector
/// breakdown rows and the review tab's per-holding sector cells.
/// Add new abbreviations here when a sector label keeps overflowing
/// the columns it lives in.
pub fn abbreviateSector(s: []const u8) []const u8 {
    if (std.mem.eql(u8, s, "Communication Services")) return "Comm. Services";
    return s;
}

/// Map a sector string through the chosen granularity. Returns
/// a static literal (at coarse) or the input slice (at fine)
/// suitable for use as a stable HashMap key.
///
/// Granularity tiers:
///
///   - **coarse**: delegates to `bucketSector` — Equity / Fixed Income
///     / Cash / Other (4 buckets).
///   - **fine**: passthrough — returns the input unchanged.
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
    return switch (granularity) {
        .fine => sector,
        .coarse => bucketSector(sector),
    };
}

/// Compute portfolio analysis from allocations and classification metadata.
/// `allocations` are the stock/ETF positions with market values.
/// `classifications` is the metadata file data.
/// `portfolio` is the full portfolio (for cash/CD/illiquid totals).
/// `account_map` is optional account tax type metadata.
/// `as_of` is the date against which lot open/closed status is
/// evaluated. Pass `null` to use wall-clock today (the default for
/// interactive commands); historical snapshot backfill passes the
/// target date so lots opened/closed/matured between `as_of` and today
/// are counted correctly.
pub fn analyzePortfolio(
    allocator: std.mem.Allocator,
    allocations: []const Allocation,
    classifications: ClassificationMap,
    portfolio: Portfolio,
    total_portfolio_value: f64,
    account_map: ?AccountMap,
    as_of: Date,
) !AnalysisResult {
    // Accumulators: label -> dollar amount.
    //
    // sector_map and asset_cat_map are both keyed by the
    // `bucket` field on ClassificationEntry (pre-filled by
    // parseClassificationFile via deriveBucket). Buckets are
    // either user-curated, GICS-like sectors, or composite
    // "{geo} {asset_class}" labels — meaningful units for
    // concentration rollup. The raw `entry.sector` is no
    // longer used for either map: NPORT-P fund-decomp
    // categories ("Equity / Corporate") would lump genuinely
    // different funds together.
    var sector_map = std.StringHashMap(f64).init(allocator);
    defer sector_map.deinit();
    // 4-bucket coarse breakdown (Equity/Fixed Income/Cash/Other).
    // Keys are static literals from `bucketSector`, no dupe needed.
    var asset_cat_map = std.StringHashMap(f64).init(allocator);
    defer asset_cat_map.deinit();
    var geo_map = std.StringHashMap(f64).init(allocator);
    defer geo_map.deinit();
    var acct_map = std.StringHashMap(f64).init(allocator);
    defer acct_map.deinit();
    var tax_map = std.StringHashMap(f64).init(allocator);
    defer tax_map.deinit();

    var unclassified_list = std.ArrayList([]const u8).empty;
    errdefer unclassified_list.deinit(allocator);

    // Process each equity allocation (for sector, geo, unclassified)
    for (allocations) |alloc| {
        const mv = alloc.market_value;
        if (mv <= 0) continue;

        // Find classification entries for this symbol
        // Try both the raw symbol and display_symbol
        var found = false;
        for (classifications.entries) |entry| {
            if (std.mem.eql(u8, entry.symbol, alloc.symbol) or
                std.mem.eql(u8, entry.symbol, alloc.display_symbol))
            {
                found = true;
                const frac = entry.pct / 100.0;
                const portion = mv * frac;

                // Sector breakdown: roll up by bucket (the
                // pre-filled deriveBucket result on the entry).
                if (entry.bucket) |b| {
                    const prev = sector_map.get(b) orelse 0;
                    try sector_map.put(b, prev + portion);
                }
                // Asset Category 4-bucket coarse breakdown
                // (Equity / Fixed Income / Cash / Other) keeps
                // using the raw `entry.sector` as input. Reasons:
                //   1. `bucketSector` recognizes the NPORT-P
                //      prefixes ("Equity / *", "Debt / *", etc.)
                //      directly. The user-facing Sector breakdown
                //      bucket might be "US ETF" (a composite that
                //      doesn't carry the asset-type signal),
                //      but the underlying sector still does.
                //   2. The Asset Category breakdown is the
                //      coarse "what's exposed to equity drawdowns?"
                //      view — invariant to the user's bucket
                //      curation, since it's a fundamental property
                //      of the holding.
                if (entry.sector) |s| {
                    const cat = bucketSector(s);
                    const cprev = asset_cat_map.get(cat) orelse 0;
                    try asset_cat_map.put(cat, cprev + portion);
                } else if (entry.asset_class) |ac| {
                    const cat = bucketAssetClass(ac);
                    const cprev = asset_cat_map.get(cat) orelse 0;
                    try asset_cat_map.put(cat, cprev + portion);
                }
                if (entry.geo) |g| {
                    const prev = geo_map.get(g) orelse 0;
                    try geo_map.put(g, prev + portion);
                }
            }
        }
        if (!found) {
            try unclassified_list.append(allocator, alloc.display_symbol);
        }
    }

    // Build symbol -> (current_price, price_ratio) lookup from allocations.
    // For unmerged allocations, current_price already includes price_ratio (preadjusted).
    // For merged allocations, current_price is the base-ticker price (not preadjusted).
    const PriceEntry = struct { price: f64, is_preadjusted: bool };
    var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
    defer price_lookup.deinit();
    for (allocations) |alloc| {
        try price_lookup.put(alloc.symbol, .{
            .price = alloc.current_price,
            .is_preadjusted = alloc.price_ratio != 1.0,
        });
    }

    // Account breakdown from individual lots (avoids "Multiple" aggregation issue).
    // Use `lotIsOpenAsOf(as_of)` so backfilled snapshots correctly include/
    // exclude lots based on the target date. For "live" callers the right
    // thing is to pass today; the resolution happens at the call site.
    for (portfolio.lots) |lot| {
        if (!lot.lotIsOpenAsOf(as_of)) continue;
        const acct = lot.account orelse continue;
        const value: f64 = switch (lot.security_type) {
            .stock => blk: {
                if (price_lookup.get(lot.priceSymbol())) |entry| {
                    break :blk lot.marketValue(entry.price, entry.is_preadjusted);
                } else {
                    // Fallback to open_price (already in lot-specific terms)
                    break :blk lot.shares * lot.open_price;
                }
            },
            .cash => lot.shares,
            .cd => lot.shares, // face value
            .option => @abs(lot.shares) * lot.open_price,
            .illiquid, .watch => continue,
        };
        const prev = acct_map.get(acct) orelse 0;
        try acct_map.put(acct, prev + value);
    }

    // Add non-stock holdings (cash, CDs, options) into the
    // coarse asset_category breakdown. They have no entry in
    // the classification map (it's keyed by ticker), so we
    // route them to coarse buckets directly.
    const cash_total = portfolio.totalCash(as_of);
    const cd_total = portfolio.totalCdFaceValue(as_of);
    const cash_cd_total = cash_total + cd_total;
    if (cash_cd_total > 0) {
        const gprev = geo_map.get("US") orelse 0;
        try geo_map.put("US", gprev + cash_cd_total);
        // Literal cash and CDs roll into the coarse Cash bucket.
        const bprev = asset_cat_map.get(bucket_cash) orelse 0;
        try asset_cat_map.put(bucket_cash, bprev + cash_cd_total);
        // Also surface in the Sector breakdown as "Cash & CDs"
        // so users with significant cash positions see the
        // line. Without this, the Sector breakdown would
        // silently omit cash entirely.
        const sprev = sector_map.get("Cash & CDs") orelse 0;
        try sector_map.put("Cash & CDs", sprev + cash_cd_total);
    }
    const opt_total = portfolio.totalOptionCost(as_of);
    if (opt_total > 0) {
        // Options are derivatives; coarse bucket is Other.
        const bprev = asset_cat_map.get(bucket_other) orelse 0;
        try asset_cat_map.put(bucket_other, bprev + opt_total);
        // Surface in Sector breakdown too.
        const sprev = sector_map.get("Options") orelse 0;
        try sector_map.put("Options", sprev + opt_total);
    }

    // Tax type breakdown: map each account's total to its tax type
    if (account_map) |am| {
        var acct_iter = acct_map.iterator();
        while (acct_iter.next()) |kv| {
            const tt = am.taxTypeFor(kv.key_ptr.*);
            const prev = tax_map.get(tt) orelse 0;
            try tax_map.put(tt, prev + kv.value_ptr.*);
        }
    }

    // Convert maps to sorted slices
    const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;

    return .{
        .asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total),
        .sector = try mapToSortedBreakdown(allocator, sector_map, total),
        .geo = try mapToSortedBreakdown(allocator, geo_map, total),
        .account = try mapToSortedBreakdown(allocator, acct_map, total),
        .tax_type = try mapToSortedBreakdown(allocator, tax_map, total),
        .unclassified = try unclassified_list.toOwnedSlice(allocator),
        .total_value = total_portfolio_value,
    };
}

/// Convert a label->value HashMap to a sorted BreakdownItem slice (descending by value).
fn mapToSortedBreakdown(
    allocator: std.mem.Allocator,
    map: std.StringHashMap(f64),
    total: f64,
) ![]BreakdownItem {
    var items = std.ArrayList(BreakdownItem).empty;
    errdefer items.deinit(allocator);

    var iter = map.iterator();
    while (iter.next()) |kv| {
        try items.append(allocator, .{
            .label = kv.key_ptr.*,
            .value = kv.value_ptr.*,
            .weight = kv.value_ptr.* / total,
        });
    }

    // Sort descending by value
    std.mem.sort(BreakdownItem, items.items, {}, struct {
        fn f(_: void, a: BreakdownItem, b: BreakdownItem) bool {
            return a.value > b.value;
        }
    }.f);

    return items.toOwnedSlice(allocator);
}

/// Re-aggregate a raw `BreakdownItem` slice through `collapseSector`
/// at the chosen granularity. Multiple input rows that map to the
/// same coarser bucket sum into one output row. Returns a new
/// allocator-owned slice; caller frees with `allocator.free`.
///
/// Use case: `analyze` produces `result.sector` at fine
/// granularity (raw NPORT-P + GICS labels). Display callers
/// (CLI `--sector-detail`, TUI hot-key) call this to re-bucket
/// at their chosen tier. At `.fine`, the function still
/// allocates a fresh slice (so callers always free the result
/// the same way) but the per-row labels and weights are
/// identical to the input.
pub fn collapseBreakdownAtGranularity(
    allocator: std.mem.Allocator,
    items: []const BreakdownItem,
    granularity: Granularity,
    total: f64,
) ![]BreakdownItem {
    var map = std.StringHashMap(f64).init(allocator);
    defer map.deinit();

    for (items) |item| {
        const label = collapseSector(item.label, granularity);
        const prev = map.get(label) orelse 0;
        try map.put(label, prev + item.value);
    }

    return mapToSortedBreakdown(allocator, map, total);
}

test "parseAccountsFile" {
    const data =
        \\#!srfv1
        \\account::Sample Roth,tax_type::roth
        \\account::Sample Trust,tax_type::taxable
        \\account::Sample HSA,tax_type::hsa
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 3), am.entries.len);
    try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Sample Roth"));
    try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust"));
    try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA"));
    try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
}

test "parseAccountsFile: institution + account_number round-trip via findByInstitutionAccount" {
    // The import command's WF resolver, the audit reconciler's
    // schwab/fidelity match logic, and the snapshot writer all
    // depend on `findByInstitutionAccount` finding entries that
    // were parsed from `accounts.srf`. Pin the round-trip so a
    // future change to either parseAccountsFile or
    // findByInstitutionAccount can't silently drop the link.
    const data =
        \\#!srfv1
        \\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
        \\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 2), am.entries.len);
    try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
    try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
    // Wrong institution / wrong number → null.
    try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
    try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
}

test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
    const data =
        \\#!srfv1
        \\account::Riley ESPP,tax_type::taxable,cash_is_contribution:bool:true
        \\account::Joint cash,tax_type::taxable
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 2), am.entries.len);
    // Opted-in account
    try std.testing.expect(am.cashIsContribution("Riley ESPP"));
    // Default-off account
    try std.testing.expect(!am.cashIsContribution("Joint cash"));
    // Unknown account defaults to false
    try std.testing.expect(!am.cashIsContribution("Nonexistent"));
}

test "parseAccountsFile: direct_indexing default false, opt-in true" {
    const data =
        \\#!srfv1
        \\account::Tax Loss,tax_type::taxable,direct_indexing:bool:true
        \\account::Regular Brokerage,tax_type::taxable
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 2), am.entries.len);
    try std.testing.expect(am.isDirectIndexing("Tax Loss"));
    try std.testing.expect(!am.isDirectIndexing("Regular Brokerage"));
    try std.testing.expect(!am.isDirectIndexing("Nonexistent"));
}

test "parseAccountsFile: shielded omitted -> null (use tax_type default)" {
    // Default behavior: when no `shielded` override is given,
    // the field stays null and the umbrella-exposure calculation
    // uses tax_type to decide.
    const data =
        \\#!srfv1
        \\account::Sample IRA,tax_type::traditional
        \\account::Sample Brokerage,tax_type::taxable
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 2), am.entries.len);
    try std.testing.expect(am.entries[0].shielded == null);
    try std.testing.expect(am.entries[1].shielded == null);
}

test "parseAccountsFile: shielded:bool:false override parses correctly" {
    // Use case: pre-tax deferred-comp account that's NOT
    // ERISA-protected (e.g. Fidelity DCP). tax_type stays as
    // `traditional` (correct for tax purposes), `shielded` is
    // overridden to false (correct for umbrella purposes).
    const data =
        \\#!srfv1
        \\account::Sample DCP,tax_type::traditional,shielded:bool:false
        \\account::Sample IRA,tax_type::traditional
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 2), am.entries.len);
    // DCP: explicit override to false.
    try std.testing.expect(am.entries[0].shielded != null);
    try std.testing.expect(!am.entries[0].shielded.?);
    // IRA: no override, stays null.
    try std.testing.expect(am.entries[1].shielded == null);
}

test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protection trust)" {
    const data =
        \\#!srfv1
        \\account::Sample Trust,tax_type::taxable,shielded:bool:true
    ;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, data);
    defer am.deinit();

    try std.testing.expectEqual(@as(usize, 1), am.entries.len);
    try std.testing.expect(am.entries[0].shielded != null);
    try std.testing.expect(am.entries[0].shielded.?);
}

// ── umbrellaExposure ─────────────────────────────────────────

/// Helper: build an in-memory AccountMap from a literal SRF
/// string. Keeps each test compact while exercising the real
/// parsing path.
fn testParseAccountMap(comptime data: []const u8) !AccountMap {
    return parseAccountsFile(std.testing.allocator, data);
}

test "umbrellaExposure: traditional/roth/hsa default to shielded; taxable to exposed" {
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::IRA,tax_type::traditional
        \\account::Roth IRA,tax_type::roth
        \\account::HSA,tax_type::hsa
        \\account::Brokerage,tax_type::taxable
    );
    defer am.deinit();

    const accounts = [_]BreakdownItem{
        .{ .label = "IRA", .value = 1_000_000, .weight = 0.40 },
        .{ .label = "Roth IRA", .value = 500_000, .weight = 0.20 },
        .{ .label = "HSA", .value = 100_000, .weight = 0.04 },
        .{ .label = "Brokerage", .value = 900_000, .weight = 0.36 },
    };
    const u = umbrellaExposure(&accounts, am);

    try std.testing.expectApproxEqAbs(@as(f64, 2_500_000), u.total_liquid, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 1_600_000), u.shielded_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 900_000), u.exposed_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.36), u.exposed_pct, 0.001);
}

test "umbrellaExposure: shielded:bool:false override flips traditional account to exposed" {
    // The DCP case: pre-tax payroll account, traditional tax
    // treatment, but NOT ERISA-shielded. Override with
    // `shielded:bool:false` and the umbrella math counts it
    // toward exposure.
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::IRA,tax_type::traditional
        \\account::DCP,tax_type::traditional,shielded:bool:false
    );
    defer am.deinit();

    const accounts = [_]BreakdownItem{
        .{ .label = "IRA", .value = 1_000_000, .weight = 0.50 },
        .{ .label = "DCP", .value = 1_000_000, .weight = 0.50 },
    };
    const u = umbrellaExposure(&accounts, am);

    try std.testing.expectApproxEqAbs(@as(f64, 2_000_000), u.total_liquid, 1.0);
    // IRA shielded (default), DCP exposed (override).
    try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.shielded_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.exposed_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.50), u.exposed_pct, 0.001);
}

test "umbrellaExposure: shielded:bool:true override flips taxable account to shielded" {
    // Asset-protection trust, taxable for tax purposes but
    // legally shielded.
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::Trust,tax_type::taxable,shielded:bool:true
        \\account::Brokerage,tax_type::taxable
    );
    defer am.deinit();

    const accounts = [_]BreakdownItem{
        .{ .label = "Trust", .value = 500_000, .weight = 0.50 },
        .{ .label = "Brokerage", .value = 500_000, .weight = 0.50 },
    };
    const u = umbrellaExposure(&accounts, am);

    try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.shielded_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.exposed_value, 1.0);
}

test "umbrellaExposure: account not in map defaults to exposed (defensive)" {
    // Defensive default: if an account name in the breakdown
    // doesn't appear in accounts.srf, treat it as exposed
    // rather than silently shielding it. The user will see the
    // overstated exposure and notice the missing entry.
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::IRA,tax_type::traditional
    );
    defer am.deinit();

    const accounts = [_]BreakdownItem{
        .{ .label = "IRA", .value = 100_000, .weight = 0.50 },
        .{ .label = "Mystery Account", .value = 100_000, .weight = 0.50 },
    };
    const u = umbrellaExposure(&accounts, am);

    try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.shielded_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.exposed_value, 1.0);
}

test "umbrellaExposure: empty breakdown returns zeros and avoids divide-by-zero" {
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::IRA,tax_type::traditional
    );
    defer am.deinit();

    const u = umbrellaExposure(&.{}, am);
    try std.testing.expectEqual(@as(f64, 0), u.total_liquid);
    try std.testing.expectEqual(@as(f64, 0), u.shielded_value);
    try std.testing.expectEqual(@as(f64, 0), u.exposed_value);
    try std.testing.expectEqual(@as(f64, 0), u.exposed_pct);
}

test "umbrellaExposure: HSA counts as shielded" {
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::HSA,tax_type::hsa
    );
    defer am.deinit();

    const accounts = [_]BreakdownItem{
        .{ .label = "HSA", .value = 50_000, .weight = 1.0 },
    };
    const u = umbrellaExposure(&accounts, am);

    try std.testing.expectApproxEqAbs(@as(f64, 50_000), u.shielded_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_pct, 0.001);
}

test "umbrellaExposure: realistic mixed portfolio" {
    // Approximation of the user's actual portfolio shape:
    // mostly Traditional 401k (shielded), some IRAs (shielded),
    // taxable brokerage (exposed), and one DCP that LOOKS
    // traditional but isn't ERISA-shielded.
    var am = try testParseAccountMap(
        \\#!srfv1
        \\account::Sample 401k,tax_type::traditional
        \\account::Sample DCP,tax_type::traditional,shielded:bool:false
        \\account::Sample IRA,tax_type::traditional
        \\account::Sample Roth,tax_type::roth
        \\account::Sample HSA,tax_type::hsa
        \\account::Sample Trust,tax_type::taxable
    );
    defer am.deinit();

    const accounts = [_]BreakdownItem{
        .{ .label = "Sample 401k", .value = 900_000, .weight = 0.30 },
        .{ .label = "Sample DCP", .value = 1_500_000, .weight = 0.50 },
        .{ .label = "Sample IRA", .value = 200_000, .weight = 0.067 },
        .{ .label = "Sample Roth", .value = 100_000, .weight = 0.033 },
        .{ .label = "Sample HSA", .value = 50_000, .weight = 0.017 },
        .{ .label = "Sample Trust", .value = 250_000, .weight = 0.083 },
    };
    const u = umbrellaExposure(&accounts, am);

    // Shielded: 401k + IRA + Roth + HSA = 900k + 200k + 100k + 50k = 1,250,000
    // Exposed: DCP + Trust = 1,500,000 + 250,000 = 1,750,000
    try std.testing.expectApproxEqAbs(@as(f64, 3_000_000), u.total_liquid, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 1_250_000), u.shielded_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 1_750_000), u.exposed_value, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 0.5833), u.exposed_pct, 0.001);
}

test "TaxType.label" {
    try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
    try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label());
    try std.testing.expectEqualStrings("Traditional (Pre-Tax)", TaxType.traditional.label());
    try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", TaxType.hsa.label());
}

test "mapToSortedBreakdown" {
    const allocator = std.testing.allocator;
    var map = std.StringHashMap(f64).init(allocator);
    defer map.deinit();
    try map.put("Technology", 50_000);
    try map.put("Healthcare", 30_000);
    try map.put("Energy", 20_000);

    const total = 100_000.0;
    const breakdown = try mapToSortedBreakdown(allocator, map, total);
    defer allocator.free(breakdown);

    try std.testing.expectEqual(@as(usize, 3), breakdown.len);
    // Should be sorted descending by value
    try std.testing.expectEqualStrings("Technology", breakdown[0].label);
    try std.testing.expectApproxEqAbs(@as(f64, 50_000), breakdown[0].value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0.5), breakdown[0].weight, 0.001);
    try std.testing.expectEqualStrings("Healthcare", breakdown[1].label);
    try std.testing.expectEqualStrings("Energy", breakdown[2].label);
}

test "mapToSortedBreakdown empty" {
    const allocator = std.testing.allocator;
    var map = std.StringHashMap(f64).init(allocator);
    defer map.deinit();
    const breakdown = try mapToSortedBreakdown(allocator, map, 100_000.0);
    defer allocator.free(breakdown);
    try std.testing.expectEqual(@as(usize, 0), breakdown.len);
}

test "parseAccountsFile empty" {
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, "#!srfv1\n");
    defer am.deinit();
    try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}

test "parseAccountsFile missing fields" {
    // Line with only account but no tax_type -> skipped via Record.to() error.
    // Override log level to suppress expected srf log.err output that
    // would otherwise cause the test runner to report failure.
    const prev_level = std.testing.log_level;
    std.testing.log_level = .err;
    defer std.testing.log_level = prev_level;
    const allocator = std.testing.allocator;
    var am = try parseAccountsFile(allocator, "#!srfv1\naccount::Test Account\n# comment\n");
    defer am.deinit();
    try std.testing.expectEqual(@as(usize, 0), am.entries.len);
}

test "account breakdown applies price_ratio" {
    const allocator = std.testing.allocator;
    const Lot = @import("../models/portfolio.zig").Lot;

    // Three lots across two accounts:
    // - Brokerage: direct SPY (ratio 1.0)
    // - 401(k): CIT mapped to SPY (ratio 0.25, merged allocation)
    // - 401(k): CUSIP with ticker=VTTHX (ratio 5.0, unmerged allocation)
    var lots = [_]Lot{
        .{
            .symbol = "SPY",
            .shares = 100,
            .open_date = Date.fromYmd(2020, 1, 1),
            .open_price = 400,
            .account = "Brokerage",
        },
        .{
            .symbol = "CIT-SPY",
            .shares = 500,
            .open_date = Date.fromYmd(2020, 1, 1),
            .open_price = 100,
            .ticker = "SPY",
            .price_ratio = 0.25,
            .account = "401(k)",
        },
        .{
            .symbol = "CUSIP123",
            .shares = 200,
            .open_date = Date.fromYmd(2020, 1, 1),
            .open_price = 50,
            .ticker = "VTTHX",
            .price_ratio = 5.0,
            .account = "401(k)",
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };

    // Allocations as produced by portfolioSummary + mergeAllocsBySymbol:
    // SPY: merged (direct + CIT). current_price = base SPY price = 500, price_ratio = 1.0
    // VTTHX: unmerged. current_price = 30 * 5.0 = 150 (already includes ratio), price_ratio = 5.0
    const allocations = [_]Allocation{
        .{
            .symbol = "SPY",
            .display_symbol = "SPY",
            .shares = 225, // 100 + 500*0.25
            .avg_cost = 300,
            .current_price = 500, // base-ticker price (merged, ratio=1.0)
            .market_value = 112_500,
            .cost_basis = 67_500,
            .weight = 0.789,
            .unrealized_gain_loss = 45_000,
            .unrealized_return = 0.667,
            .price_ratio = 1.0, // merged
        },
        .{
            .symbol = "VTTHX",
            .display_symbol = "VTTHX",
            .shares = 200,
            .avg_cost = 50,
            .current_price = 150, // already includes price_ratio (30 * 5.0)
            .market_value = 30_000, // 200 * 150
            .cost_basis = 10_000,
            .weight = 0.211,
            .unrealized_gain_loss = 20_000,
            .unrealized_return = 2.0,
            .price_ratio = 5.0, // unmerged, ratio preserved
        },
    };

    const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        142_500,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    // Expected account values:
    // Brokerage: SPY direct, 100 shares * $500 * 1.0 = $50,000
    // 401(k): CIT-SPY 500 shares * $500 * 0.25 = $62,500
    //       + CUSIP123 200 shares * $150 (already includes ratio) = $30,000
    //       = $92,500
    // Total: $142,500
    for (result.account) |item| {
        if (std.mem.eql(u8, item.label, "Brokerage")) {
            try std.testing.expectApproxEqAbs(@as(f64, 50_000), item.value, 1.0);
        } else if (std.mem.eql(u8, item.label, "401(k)")) {
            try std.testing.expectApproxEqAbs(@as(f64, 92_500), item.value, 1.0);
        }
    }

    // Sum of accounts must equal total portfolio value
    var account_sum: f64 = 0;
    for (result.account) |item| {
        account_sum += item.value;
    }
    try std.testing.expectApproxEqAbs(@as(f64, 142_500), account_sum, 1.0);
}

// ── bucketSector ──────────────────────────────────────────────

test "bucketSector: NPORT-P Debt / * → Fixed Income" {
    const cases = [_][]const u8{
        "Debt / Corporate",
        "Debt / US Treasury",
        "Debt / Municipal",
        "Debt / Non-US Sovereign",
        "Debt / US Gov Agency",
        "Debt / US GSE",
    };
    for (cases) |s| {
        try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector(s));
    }
}

test "bucketSector: NPORT-P Equity / * and Equity Preferred / * → Equity" {
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate"));
}

test "bucketSector: NPORT-P Loan / * → Fixed Income" {
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate"));
}

test "bucketSector: NPORT-P Asset-Backed variants → Fixed Income" {
    // All three asset-backed prefixes should bucket the same
    // way. Asset-backed securities are bond-like by structure.
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / US GSE Mortgage"));
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed CBO/CDO / Corporate"));
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate"));
}

test "bucketSector: Short-Term Investment Vehicle / * → Cash" {
    try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate"));
    try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund"));
    try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund"));
}

test "bucketSector: Repurchase Agreement / * → Cash" {
    // PTY-style leverage liability sleeve. Bucket is Cash; the
    // negative pct flows through honestly into bucket math.
    try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other"));
}

test "bucketSector: Derivative variants → Other" {
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate"));
}

test "bucketSector: Direct Real Property and Direct Credit Risk → Other" {
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other"));
}

test "bucketSector: GICS sector names → Equity" {
    const gics = [_][]const u8{
        "Technology",
        "Healthcare",
        "Financial Services",
        "Consumer Cyclical",
        "Consumer Defensive",
        "Energy",
        "Utilities",
        "Real Estate",
        "Industrials",
        "Basic Materials",
        "Communication Services",
    };
    for (gics) |s| {
        try std.testing.expectEqualStrings(bucket_equity, bucketSector(s));
    }
}

test "bucketSector: sentinels stay Other" {
    try std.testing.expectEqualStrings(bucket_other, bucketSector("TODO"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector(""));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Unclassified"));
}

test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
    // After the bucket commit, `bucketSector` is called with
    // either NPORT-P-shaped strings, GICS sector names, or
    // composite/curated bucket labels (from `deriveBucket` or
    // user-curated `bucket::` overrides). For composite-shaped
    // strings that don't match any explicit Bonds/Cash/Options
    // pattern, the default is Equity — composite buckets
    // describe equity sleeves unless they say otherwise. This
    // is the right default because:
    //   1. The user's primary use of the Asset Category
    //      breakdown is "what fraction is exposed to equity
    //      drawdowns?" — a curated bucket like "US Large Cap"
    //      definitely IS equity.
    //   2. The cost of the wrong default is asymmetric: a real
    //      bond bucket mis-bucketed as Equity will show in the
    //      4-bucket coarse breakdown as overweight equity (very
    //      visible bug). A real equity bucket mis-bucketed as
    //      Other will silently disappear from the
    //      stocks/bonds/cash header (very subtle bug).
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Fintech"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Some Future Label"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Large Cap"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Mid Cap"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Small Cap"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Dividend Equity"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Healthcare ETF"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("International Developed"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets"));
}

test "bucketSector: composite Bonds buckets → Fixed Income" {
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds"));
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds"));
}

test "bucketSector: composite Cash buckets → Cash" {
    try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs"));
}

test "bucketSector: Options keyword → Other" {
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Options"));
}

test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) → Other" {
    // Strings containing `/` that didn't match any specific
    // NPORT-P prefix branch are real-property / credit-risk /
    // miscellaneous categories. Bucket as Other.
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Other / Corporate"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
}

test "bucketSector: returns same pointer for repeated calls (static-string property)" {
    // Both callers use the result as a HashMap key. Stability of
    // the pointer (not just equality of bytes) is what makes
    // this safe without any dupe.
    const a = bucketSector("Debt / Corporate");
    const b = bucketSector("Debt / US Treasury");
    try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr));
    try std.testing.expectEqual(@intFromPtr(bucketSector("Equity / Corporate").ptr), @intFromPtr(bucket_equity.ptr));
    try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr));
}

test "bucketSector: case-sensitive (defensive — bad input lands in Other, not crash)" {
    // We don't normalize case. "debt / corporate" doesn't match
    // "Debt / Corporate" so it falls through to Other. Tests the
    // contract: only canonical strings are recognized.
    try std.testing.expectEqualStrings(bucket_other, bucketSector("debt / corporate"));
    try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE"));
}

test "bucketSector: legacy hand-written 'Bonds' → Fixed Income" {
    // metadata.srf entries that pre-date EDGAR fund decomposition
    // use the literal word `Bonds` as the sector. Map to Fixed
    // Income so the Asset Category breakdown picks them up
    // alongside the NPORT-P `Debt / *` rows.
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds"));
}

test "bucketSector: legacy hand-written 'Cash' → Cash" {
    try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash"));
}

test "bucketSector: legacy 'Diversified' → Equity (broad equity fund)" {
    // "Diversified" in practice means an S&P 500 / total-market
    // index fund holding all sectors — overwhelmingly equity.
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified"));
}

test "bucketSector: legacy 'Financials' (with s) → Equity" {
    // Wikidata's canonical name is "Financial Services"; older
    // hand-written entries use "Financials". Both must map to
    // Equity so legacy data doesn't silently land in Other.
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financials"));
    try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financial Services"));
}

// ── collapseSector / Granularity ──────────────────────────────

test "collapseSector .fine: passthrough — input slice returned unchanged" {
    try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine));
    try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
    try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
    try std.testing.expectEqualStrings("Bonds", collapseSector("Bonds", .fine));
}

test "collapseSector .coarse: delegates to bucketSector" {
    try std.testing.expectEqualStrings(bucket_fixed_income, collapseSector("Debt / US Treasury", .coarse));
    try std.testing.expectEqualStrings(bucket_equity, collapseSector("Equity / Corporate", .coarse));
    try std.testing.expectEqualStrings(bucket_equity, collapseSector("Technology", .coarse));
    try std.testing.expectEqualStrings(bucket_cash, collapseSector("Short-Term Investment Vehicle / Registered Fund", .coarse));
    try std.testing.expectEqualStrings(bucket_other, collapseSector("Derivative / Other", .coarse));
}

// ── collapseBreakdownAtGranularity ────────────────────────────

test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" {
    const allocator = std.testing.allocator;
    const items = [_]BreakdownItem{
        .{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
        .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
        .{ .label = "Technology", .weight = 0.10, .value = 10_000.0 },
        .{ .label = "Short-Term Investment Vehicle / Corporate", .weight = 0.05, .value = 5_000.0 },
        .{ .label = "Derivative / Other", .weight = 0.05, .value = 5_000.0 },
    };
    const result = try collapseBreakdownAtGranularity(allocator, &items, .coarse, 100_000.0);
    defer allocator.free(result);

    // Equity (30k Equity/Corp + 10k Technology) = 40k
    // Fixed Income = 50k Debt
    // Cash = 5k STIV
    // Other = 5k Derivative
    var eq: f64 = 0;
    var fi: f64 = 0;
    var c: f64 = 0;
    var o: f64 = 0;
    for (result) |item| {
        if (std.mem.eql(u8, item.label, bucket_equity)) eq = item.value;
        if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi = item.value;
        if (std.mem.eql(u8, item.label, bucket_cash)) c = item.value;
        if (std.mem.eql(u8, item.label, bucket_other)) o = item.value;
    }
    try std.testing.expectApproxEqAbs(@as(f64, 40_000), eq, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 50_000), fi, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 5_000), c, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 5_000), o, 1.0);
}

test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchanged" {
    const allocator = std.testing.allocator;
    const items = [_]BreakdownItem{
        .{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
        .{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
        .{ .label = "Technology", .weight = 0.20, .value = 20_000.0 },
    };
    const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
    defer allocator.free(result);

    // 3 input rows → 3 output rows (no collapsing at fine).
    try std.testing.expectEqual(@as(usize, 3), result.len);
    // Output sorted by value descending.
    try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
    try std.testing.expectApproxEqAbs(@as(f64, 50_000), result[0].value, 1.0);
}

test "collapseBreakdownAtGranularity: empty input -> empty output" {
    const allocator = std.testing.allocator;
    const items = [_]BreakdownItem{};
    const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
    defer allocator.free(result);
    try std.testing.expectEqual(@as(usize, 0), result.len);
}

test "collapseBreakdownAtGranularity: total values preserved through collapse" {
    // Sum of output values must equal sum of input values
    // (modulo float rounding).
    const allocator = std.testing.allocator;
    const items = [_]BreakdownItem{
        .{ .label = "Debt / Corporate", .weight = 0.40, .value = 40.0 },
        .{ .label = "Loan / Corporate", .weight = 0.20, .value = 20.0 },
        .{ .label = "Asset-Backed / Corporate Mortgage", .weight = 0.15, .value = 15.0 },
        .{ .label = "Equity / Corporate", .weight = 0.20, .value = 20.0 },
        .{ .label = "Technology", .weight = 0.05, .value = 5.0 },
    };
    const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100.0);
    defer allocator.free(result);

    var total: f64 = 0;
    for (result) |item| total += item.value;
    try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01);
}

/// Map an `asset_class` string to one of the four asset-category
/// buckets. Used as a fallback when a classification entry has
/// no `sector` but does have an `asset_class` (legacy
/// hand-written entries for CITs / CUSIPs / blended funds where
/// the user wrote `asset_class::Bonds,pct:num:30` without
/// a sector). Returns `bucket_other` for unrecognized values.
pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
    if (std.mem.eql(u8, asset_class, "Bonds")) return bucket_fixed_income;
    if (std.mem.eql(u8, asset_class, "Cash")) return bucket_cash;
    if (std.mem.eql(u8, asset_class, "Cash & CDs")) return bucket_cash;
    // US size buckets and international/EM buckets are all equity.
    if (std.mem.eql(u8, asset_class, "US Large Cap")) return bucket_equity;
    if (std.mem.eql(u8, asset_class, "US Mid Cap")) return bucket_equity;
    if (std.mem.eql(u8, asset_class, "US Small Cap")) return bucket_equity;
    if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
    if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity;
    // Mutual Fund / ETF / Fund are too generic to bucket without
    // sector data — fall through to Other rather than guess
    // wrong. The companion `sector` field should already have
    // bucketed these via `bucketSector`; if it didn't, that's a
    // metadata-quality signal (TODO sector that needs filling
    // in) and Other is the right label.
    return bucket_other;
}

// ── bucketAssetClass ──────────────────────────────────────────

test "bucketAssetClass: Bonds → Fixed Income" {
    try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds"));
}

test "bucketAssetClass: Cash variants → Cash" {
    try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash"));
    try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs"));
}

test "bucketAssetClass: US size buckets → Equity" {
    try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap"));
    try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
    try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap"));
}

test "bucketAssetClass: international + EM → Equity" {
    try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed"));
    try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets"));
}

test "bucketAssetClass: generic Fund/ETF/Mutual Fund → Other (not enough info)" {
    // The companion `sector` field is what disambiguates Fund-typed
    // entries. If sector is missing too, calling these "Equity"
    // would be a guess; Other is the honest label that signals
    // a metadata-quality issue (sector::TODO needs filling in).
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Fund"));
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("ETF"));
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund"));
}

test "bucketAssetClass: unknown / sentinels → Other" {
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass(""));
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class"));
}

test "bucketAssetClass: case-sensitive — bad case lands in Other" {
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds"));
    try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
}

test "bucketAssetClass: returns same pointer for same bucket (static-string property)" {
    // Same invariant as bucketSector — result is a stable
    // HashMap key without dupe.
    try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr));
    try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr));
    try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Cash").ptr), @intFromPtr(bucket_cash.ptr));
    try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Fund").ptr), @intFromPtr(bucket_other.ptr));
}

// ── breakdownSections ─────────────────────────────────────────

test "breakdownSections: returns 5 sections" {
    var ac_cat = [_]BreakdownItem{};
    var sec = [_]BreakdownItem{};
    var geo = [_]BreakdownItem{};
    var acct = [_]BreakdownItem{};
    var tax = [_]BreakdownItem{};
    const result = AnalysisResult{
        .asset_category = &ac_cat,
        .sector = &sec,
        .geo = &geo,
        .account = &acct,
        .tax_type = &tax,
        .unclassified = &.{},
        .total_value = 0,
    };
    const sections = breakdownSections(&result);
    try std.testing.expectEqual(@as(usize, 5), sections.len);
}

test "breakdownSections: titles in expected order, no leading whitespace, unique" {
    var ac_cat = [_]BreakdownItem{};
    var sec = [_]BreakdownItem{};
    var geo = [_]BreakdownItem{};
    var acct = [_]BreakdownItem{};
    var tax = [_]BreakdownItem{};
    const result = AnalysisResult{
        .asset_category = &ac_cat,
        .sector = &sec,
        .geo = &geo,
        .account = &acct,
        .tax_type = &tax,
        .unclassified = &.{},
        .total_value = 0,
    };
    const sections = breakdownSections(&result);

    const expected = [_][]const u8{
        "Asset Category",
        "Sector",
        "Geographic",
        "By Account",
        "By Tax Type",
    };
    for (sections, expected) |s, want| {
        try std.testing.expectEqualStrings(want, s.title);
        // No leading whitespace baked into the title — renderers
        // own indent.
        try std.testing.expect(s.title.len > 0);
        try std.testing.expect(s.title[0] != ' ');
        try std.testing.expect(s.title[0] != '\t');
    }
    // Titles must be unique.
    for (sections, 0..) |a, i| {
        for (sections[i + 1 ..]) |b| {
            try std.testing.expect(!std.mem.eql(u8, a.title, b.title));
        }
    }
}

test "breakdownSections: items.ptr points to AnalysisResult fields" {
    // The single-source-of-truth promise: each section borrows
    // from the corresponding AnalysisResult field. Catches anyone
    // sliding in a copy or reordering the fields.
    var ac_cat = [_]BreakdownItem{
        .{ .label = "Equity", .weight = 1.0, .value = 100.0 },
    };
    var sec = [_]BreakdownItem{
        .{ .label = "Technology", .weight = 0.5, .value = 50.0 },
    };
    var geo = [_]BreakdownItem{};
    var acct = [_]BreakdownItem{};
    var tax = [_]BreakdownItem{};
    const result = AnalysisResult{
        .asset_category = &ac_cat,
        .sector = &sec,
        .geo = &geo,
        .account = &acct,
        .tax_type = &tax,
        .unclassified = &.{},
        .total_value = 100,
    };
    const sections = breakdownSections(&result);

    try std.testing.expectEqual(result.asset_category.ptr, sections[0].items.ptr);
    try std.testing.expectEqual(result.sector.ptr, sections[1].items.ptr);
    try std.testing.expectEqual(result.geo.ptr, sections[2].items.ptr);
    try std.testing.expectEqual(result.account.ptr, sections[3].items.ptr);
    try std.testing.expectEqual(result.tax_type.ptr, sections[4].items.ptr);
}

test "breakdownSections: Asset Category is first (coarse-to-fine ordering)" {
    var ac_cat = [_]BreakdownItem{};
    var sec = [_]BreakdownItem{};
    var geo = [_]BreakdownItem{};
    var acct = [_]BreakdownItem{};
    var tax = [_]BreakdownItem{};
    const result = AnalysisResult{
        .asset_category = &ac_cat,
        .sector = &sec,
        .geo = &geo,
        .account = &acct,
        .tax_type = &tax,
        .unclassified = &.{},
        .total_value = 0,
    };
    const sections = breakdownSections(&result);
    // Asset Category (4 buckets) is the coarsest view; should
    // come first so the user sees the headline number before
    // the finer breakdowns.
    try std.testing.expectEqualStrings("Asset Category", sections[0].title);
}

// ── analyzePortfolio: asset_category aggregation ──────────────

/// Helper: minimal Allocation for asset-category tests. Only
/// the fields read by `analyzePortfolio`'s sector loop matter.
fn mkAlloc(symbol: []const u8, mv: f64) Allocation {
    return .{
        .symbol = symbol,
        .display_symbol = symbol,
        .shares = 1,
        .avg_cost = mv,
        .current_price = mv,
        .market_value = mv,
        .cost_basis = mv,
        .weight = 1.0,
        .unrealized_gain_loss = 0.0,
        .unrealized_return = 0.0,
    };
}

test "analyzePortfolio: multi-sector fund (FAGIX shape) splits asset_category buckets" {
    const allocator = std.testing.allocator;
    const allocations = [_]Allocation{mkAlloc("FAGIX", 100_000)};
    var entries = [_]ClassificationEntry{
        .{ .symbol = "FAGIX", .sector = "Debt / Corporate", .pct = 47.69 },
        .{ .symbol = "FAGIX", .sector = "Equity / Corporate", .pct = 22.49 },
        .{ .symbol = "FAGIX", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 13.37 },
        .{ .symbol = "FAGIX", .sector = "Loan / Corporate", .pct = 9.99 },
        .{ .symbol = "FAGIX", .sector = "Equity Preferred / Corporate", .pct = 3.59 },
    };
    const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        100_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    // Find each bucket's value.
    var equity_val: f64 = 0;
    var fi_val: f64 = 0;
    var cash_val: f64 = 0;
    for (result.asset_category) |item| {
        if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
        if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
        if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
    }
    // Equity = 22.49 + 3.59 = 26.08% of $100K = $26,080
    try std.testing.expectApproxEqAbs(@as(f64, 26_080), equity_val, 1.0);
    // Fixed Income = 47.69 + 9.99 = 57.68% of $100K = $57,680
    try std.testing.expectApproxEqAbs(@as(f64, 57_680), fi_val, 1.0);
    // Cash = 13.37% of $100K = $13,370
    try std.testing.expectApproxEqAbs(@as(f64, 13_370), cash_val, 1.0);
}

test "analyzePortfolio: pure-stock fund (SCHD shape) lands in Equity + tiny Cash" {
    const allocator = std.testing.allocator;
    const allocations = [_]Allocation{mkAlloc("SCHD", 100_000)};
    var entries = [_]ClassificationEntry{
        .{ .symbol = "SCHD", .sector = "Equity / Corporate", .pct = 99.70 },
        .{ .symbol = "SCHD", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 0.19 },
    };
    const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        100_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    var equity_val: f64 = 0;
    var cash_val: f64 = 0;
    for (result.asset_category) |item| {
        if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
        if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
    }
    try std.testing.expectApproxEqAbs(@as(f64, 99_700), equity_val, 1.0);
    try std.testing.expectApproxEqAbs(@as(f64, 190), cash_val, 1.0);
}

test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" {
    const allocator = std.testing.allocator;
    const allocations = [_]Allocation{mkAlloc("NVDA", 50_000)};
    var entries = [_]ClassificationEntry{
        .{ .symbol = "NVDA", .sector = "Technology" },
    };
    const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        50_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
    try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
    try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0);
}

test "analyzePortfolio: empty portfolio produces empty asset_category" {
    const allocator = std.testing.allocator;
    const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &.{},
        cm,
        portfolio,
        0,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
}

test "analyzePortfolio: PTY-shape negative repo flows honestly into Cash bucket" {
    // Portfolio has only PTY. Repo line is negative; bucket math
    // sums it honestly. Cash bucket value is the (negative)
    // repo contribution alone, since this fund has no Cash
    // SIV sleeve.
    const allocator = std.testing.allocator;
    const allocations = [_]Allocation{mkAlloc("PTY", 10_000)};
    var entries = [_]ClassificationEntry{
        .{ .symbol = "PTY", .sector = "Debt / Corporate", .pct = 41.65 },
        .{ .symbol = "PTY", .sector = "Loan / Corporate", .pct = 40.05 },
        .{ .symbol = "PTY", .sector = "Equity / Corporate", .pct = 5.78 },
        .{ .symbol = "PTY", .sector = "Repurchase Agreement / Other", .pct = -29.72 },
    };
    const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        10_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    var cash_val: f64 = 0;
    var fi_val: f64 = 0;
    var equity_val: f64 = 0;
    for (result.asset_category) |item| {
        if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
        if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
        if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
    }
    // Cash = -29.72% × $10,000 = -$2,972 (honest negative).
    try std.testing.expectApproxEqAbs(@as(f64, -2_972), cash_val, 1.0);
    // Fixed Income = (41.65 + 40.05)% × $10,000 = $8,170.
    try std.testing.expectApproxEqAbs(@as(f64, 8_170), fi_val, 1.0);
    // Equity = 5.78% × $10,000 = $578.
    try std.testing.expectApproxEqAbs(@as(f64, 578), equity_val, 1.0);
}

test "analyzePortfolio: asset_category includes literal cash + CD totals in Cash bucket" {
    // Literal cash and CDs should add to the Cash bucket's
    // value, not just Cash & CDs in the asset_class breakdown.
    const allocator = std.testing.allocator;
    const Lot = @import("../models/portfolio.zig").Lot;
    var lots = [_]Lot{
        .{
            .symbol = "CASH",
            .shares = 50_000,
            .open_date = Date.fromYmd(2020, 1, 1),
            .open_price = 1.0,
            .security_type = .cash,
            .account = "Brokerage",
        },
        .{
            .symbol = "CD-1",
            .shares = 10_000, // face value
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 1.0,
            .security_type = .cd,
            .account = "Brokerage",
            .maturity_date = Date.fromYmd(2027, 1, 1),
        },
    };
    const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
    const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &.{},
        cm,
        portfolio,
        60_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    var cash_val: f64 = 0;
    for (result.asset_category) |item| {
        if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
    }
    try std.testing.expectApproxEqAbs(@as(f64, 60_000), cash_val, 1.0);
}

test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via fallback" {
    // Hand-written CIT/CUSIP entries in metadata.srf often have
    // `asset_class::Bonds,pct:num:30` with no sector. The
    // fallback path through `bucketAssetClass` must pick these
    // up so they land in Fixed Income, not Other.
    const allocator = std.testing.allocator;
    const allocations = [_]Allocation{mkAlloc("LEGACY-CIT", 100_000)};
    var entries = [_]ClassificationEntry{
        .{ .symbol = "LEGACY-CIT", .asset_class = "Bonds", .pct = 60 },
        .{ .symbol = "LEGACY-CIT", .asset_class = "US Large Cap", .pct = 40 },
    };
    const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        100_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    var equity_val: f64 = 0;
    var fi_val: f64 = 0;
    for (result.asset_category) |item| {
        if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
        if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
    }
    // 60% Bonds → Fixed Income = $60,000.
    try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0);
    // 40% US Large Cap → Equity = $40,000.
    try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0);
}

test "analyzePortfolio: sector wins over asset_class when both present" {
    // Defensive: we should not double-count. If both fields are
    // present, only the sector-based bucket fires.
    const allocator = std.testing.allocator;
    const allocations = [_]Allocation{mkAlloc("FOO", 100_000)};
    var entries = [_]ClassificationEntry{
        // sector says Fixed Income (Debt / *), asset_class says
        // Equity (US Large Cap). sector should win.
        .{ .symbol = "FOO", .sector = "Debt / Corporate", .asset_class = "US Large Cap" },
    };
    const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
    const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };

    var result = try analyzePortfolio(
        allocator,
        &allocations,
        cm,
        portfolio,
        100_000,
        null,
        Date.fromYmd(2024, 6, 1),
    );
    defer result.deinit(allocator);

    // Exactly one row, in Fixed Income.
    try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
    try std.testing.expectEqualStrings(bucket_fixed_income, result.asset_category[0].label);
    try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.asset_category[0].value, 1.0);
}

test "abbreviateSector: known long labels collapse, others pass through" {
    try std.testing.expectEqualStrings("Comm. Services", abbreviateSector("Communication Services"));
    try std.testing.expectEqualStrings("Technology", abbreviateSector("Technology"));
    try std.testing.expectEqualStrings("Bonds", abbreviateSector("Bonds"));
    try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
    try std.testing.expectEqualStrings("", abbreviateSector(""));
}
