//! Broker-agnostic reconciliation engine (pure compute).
//!
//! Holds the pieces every per-account positions reconciler needs: the
//! normalized comparison types (`SymbolComparison` / `AccountComparison`),
//! the portfolio-vs-export comparator (`compareAccounts`, parameterized
//! by institution string), the price-provenance helper
//! (`resolvePositionValue`), CD/option value-expectation helpers, and
//! absent-account detection.
//!
//! Per-broker modules (`fidelity.zig`, `schwab.zig`) consume their
//! `brokerage/*` parser and feed the parsed positions into this engine.
//! The Schwab-summary path is the one genuinely broker-specific
//! reconciler and lives in `schwab.zig`.
//!
//! This is pure compute: no CLI, no color, no writer. The ANSI
//! renderers that consume these results live in `commands/audit/`, and
//! finrev consumes the same functions to propose portfolio edits.

const std = @import("std");
const zfin = @import("../../root.zig");
const analysis = @import("../../analytics/analysis.zig");
const brokerage_types = @import("../../brokerage/types.zig");
const portfolio_mod = @import("../../models/portfolio.zig");
const option = @import("../../models/option.zig");
const Date = @import("../../Date.zig");

const BrokeragePosition = brokerage_types.BrokeragePosition;

/// Reconciliation match tolerances.
///
/// Securities get $1 of slack to absorb NAV-rounding on large
/// positions: a sub-cent per-share NAV difference between the broker
/// and zfin's fetched price on a six-figure mutual-fund position
/// easily exceeds a dollar, and that's not an actionable discrepancy.
///
/// Cash is different - it has no NAV and no share count, it's an exact
/// dollar figure on both sides. It must match to the penny; the $1
/// securities slack would otherwise silently hide real money-market
/// dividend accrual between updates (the whole point of the audit).
pub const value_tolerance: f64 = 1.0;
pub const cash_tolerance: f64 = 0.01;

/// A CD's reconciliation allowance is capped at one year's coupon:
/// years-to-maturity is clamped to 1.0 so a long-dated CD doesn't open
/// an unbounded muting window. (Near-maturity CDs - the common case -
/// get a tight band; e.g. a $87k 3.8% CD 17 days out -> ~$154.)
pub const cd_allowance_year_cap: f64 = 1.0;
/// Fallback band (percent of face) for a CD that carries no `rate`.
/// Rare, but we still want a non-zero allowance so a rate-less CD isn't
/// flagged on every routine dealer mark.
pub const cd_allowance_fallback_pct: f64 = 0.5;

/// Expected secondary-market mark on a single open CD, in dollars.
///
/// zfin values a CD at face (`shares`); the broker marks it to the
/// secondary market, which drifts from face by a small, bounded amount
/// driven by the remaining coupon and time to maturity. We can't
/// reproduce the broker's exact mark without a live yield, but we can
/// bound it: `face x (rate/100) x years_to_maturity` (years capped at
/// 1.0). A value delta within this band is an expected CD mark (mute);
/// beyond it, something else is off (warn). Returns 0 for non-CD lots.
pub fn cdLotAllowance(lot: portfolio_mod.Lot, as_of: Date) f64 {
    if (lot.security_type != .cd) return 0;
    const face = @abs(lot.shares);
    const rate = lot.rate orelse return face * (cd_allowance_fallback_pct / 100.0);
    const years: f64 = if (lot.maturity_date) |m| blk: {
        const y = Date.yearsBetween(as_of, m);
        break :blk if (y < 0) 0 else @min(y, cd_allowance_year_cap);
    } else cd_allowance_year_cap;
    return face * (rate / 100.0) * years;
}

/// Per-account, broker-agnostic classifier for "expected" value
/// differences between zfin and a brokerage. Reconcilers use it to
/// decide whether an account-level value delta should be muted
/// (expected) or warned (real discrepancy).
///
///   - `has_options`: the account holds >= 1 open option lot. zfin
///     values options at cost (premium) while brokers mark them to
///     market, so the gap is real but unbounded and unknowable without
///     a live option quote. At the totals-only summary level any value
///     delta is therefore "expected" - mute it. (The per-position path
///     carves options out symbol-by-symbol instead; this is the
///     totals-only fallback.)
///   - `cd_allowance`: summed `cdLotAllowance` over the account's open
///     CDs - the bounded band within which a value delta is explained
///     by CD dealer marks.
pub const AccountValueExpectation = struct {
    has_options: bool = false,
    cd_allowance: f64 = 0,
};

/// Walk an account's open lots and summarize its expected value
/// differences. See `AccountValueExpectation`.
pub fn accountValueExpectation(portfolio: zfin.Portfolio, as_of: Date, account_name: []const u8) AccountValueExpectation {
    var result: AccountValueExpectation = .{};
    for (portfolio.lots) |lot| {
        const lot_acct = lot.account orelse continue;
        if (!std.mem.eql(u8, lot_acct, account_name)) continue;
        if (!lot.isOpen(as_of)) continue;
        switch (lot.security_type) {
            .option => result.has_options = true,
            .cd => result.cd_allowance += cdLotAllowance(lot, as_of),
            else => {},
        }
    }
    return result;
}

/// Resolved position value for audit display: effective per-share price
/// and total market value, with correct `price_ratio` handling based on
/// the price's provenance.
///
/// Two sources feed `prices`:
///   1. Live candle close - NOT preadjusted for the lot's share class,
///      so `price_ratio` must be applied.
///   2. `pos.avg_cost` fallback - already in the lot's share-class
///      terms (user paid institutional-class prices to open the lot),
///      so `price_ratio` must be skipped.
///
/// See the "Pricing model" block in `models/portfolio.zig` for the full
/// treatment. This helper is the audit-side mirror of the snapshot
/// side's `buildFallbackPrices` + `manual_set` pair.
const ResolvedValue = struct { price: f64, value: f64 };

fn resolvePositionValue(pos: zfin.Position, prices: std.StringHashMap(f64)) ResolvedValue {
    if (prices.get(pos.symbol)) |live| {
        return .{
            .price = pos.effectivePrice(live, false),
            .value = pos.marketValue(live, false),
        };
    }
    // Fallback: avg_cost. Already preadjusted.
    return .{
        .price = pos.effectivePrice(pos.avg_cost, true),
        .value = pos.marketValue(pos.avg_cost, true),
    };
}

// ── Audit logic ─────────────────────────────────────────────

/// Comparison result for a single symbol within an account.
pub const SymbolComparison = struct {
    symbol: []const u8,
    portfolio_shares: f64,
    brokerage_shares: ?f64,
    portfolio_price: ?f64,
    brokerage_price: ?f64,
    portfolio_value: f64,
    brokerage_value: ?f64,
    shares_delta: ?f64,
    value_delta: ?f64,
    is_cash: bool,
    is_option: bool,
    /// True when the matched portfolio lot(s) are a CD. A CD's value
    /// delta is muted up to `cd_allowance`; beyond that it warns.
    is_cd: bool = false,
    /// Summed `cdLotAllowance` for the matched CD lot(s). Zero unless
    /// `is_cd`.
    cd_allowance: f64 = 0,
    only_in_brokerage: bool,
    only_in_portfolio: bool,
};

/// Comparison result for a single account.
pub const AccountComparison = struct {
    account_name: []const u8,
    brokerage_name: []const u8,
    account_number: []const u8,
    comparisons: []const SymbolComparison,
    portfolio_total: f64,
    brokerage_total: f64,
    total_delta: f64,
    option_value_delta: f64,
    has_discrepancies: bool,
};

/// Consolidate broker rows that share a symbol within the same
/// account into a single position. Some brokers split a single
/// stock holding into separate "Cash" and "Margin" rows for the
/// same ticker in the same account - Fidelity does this when a
/// freshly-credited lot (e.g. an RSU distribution) hasn't yet
/// cleared settlement (T+1 / T+2) and is therefore considered
/// un-marginable, while the older settled shares stay in the
/// margin sub-account. Without consolidation, the audit would
/// double-count when matching against the portfolio's
/// account-level aggregate.
///
/// Aggregation rules:
///   - `quantity` and `current_value` are summed across rows
///     (treating null as 0 for the sum, but preserving null when
///     no row supplied a value).
///   - `cost_basis` is summed the same way.
///   - `is_cash` is OR-ed across rows: any cash row in the group
///     marks the consolidated entry as cash. In practice a single
///     symbol is either always-cash (money market) or never (stock),
///     so this is just defensive.
///   - `account_number`, `account_name`, `description` are taken
///     from the first row in the group.
///
/// Caller owns the returned ArrayList.
fn consolidateBySymbol(
    allocator: std.mem.Allocator,
    rows: []const BrokeragePosition,
) !std.ArrayList(BrokeragePosition) {
    var by_symbol = std.StringHashMap(usize).init(allocator);
    defer by_symbol.deinit();

    var out: std.ArrayList(BrokeragePosition) = .empty;
    errdefer out.deinit(allocator);

    for (rows) |bp| {
        if (by_symbol.get(bp.symbol)) |idx| {
            const existing = &out.items[idx];
            // Sum quantity (null + value = value; null + null = null).
            existing.quantity = sumOptional(existing.quantity, bp.quantity);
            existing.current_value = sumOptional(existing.current_value, bp.current_value);
            existing.cost_basis = sumOptional(existing.cost_basis, bp.cost_basis);
            existing.is_cash = existing.is_cash or bp.is_cash;
        } else {
            try by_symbol.put(bp.symbol, out.items.len);
            try out.append(allocator, bp);
        }
    }

    return out;
}

fn sumOptional(a: ?f64, b: ?f64) ?f64 {
    if (a == null and b == null) return null;
    return (a orelse 0) + (b orelse 0);
}

/// Build per-account comparisons between portfolio.srf and brokerage data.
pub fn compareAccounts(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    brokerage_positions: []const BrokeragePosition,
    account_map: analysis.AccountMap,
    institution: []const u8,
    prices: std.StringHashMap(f64),
    as_of: Date,
) ![]AccountComparison {
    var results = std.ArrayList(AccountComparison).empty;
    errdefer results.deinit(allocator);

    // Group brokerage positions by account number
    var brokerage_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator);
    defer {
        var it = brokerage_accounts.valueIterator();
        while (it.next()) |v| v.deinit(allocator);
        brokerage_accounts.deinit();
    }

    for (brokerage_positions) |bp| {
        const entry = try brokerage_accounts.getOrPut(bp.account_number);
        if (!entry.found_existing) {
            entry.value_ptr.* = .empty;
        }
        try entry.value_ptr.append(allocator, bp);
    }

    // Aggregate same-symbol rows within each account. Some brokers
    // report a single security as multiple rows when a position
    // straddles sub-account contexts. The motivating case is
    // Fidelity's margin-eligible accounts: when a freshly-credited
    // lot (e.g. an RSU distribution) hasn't yet cleared settlement
    // (T+1 / T+2), Fidelity classifies the new shares as
    // un-marginable "Cash" and the older settled shares as
    // "Margin", reporting them as two CSV rows for the same
    // ticker in the same account number. Once settlement clears,
    // the rows usually consolidate back into one - but until
    // then, the audit needs to consolidate them itself, otherwise
    // it'd match each broker row independently against the
    // (already-aggregated) portfolio total and report a phantom
    // discrepancy on every duplicate. Aggregating here lets the
    // rest of the comparator stay (account, symbol)-keyed
    // regardless of how the broker chose to slice the rows.
    var consolidated_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator);
    defer {
        var it = consolidated_accounts.valueIterator();
        while (it.next()) |v| v.deinit(allocator);
        consolidated_accounts.deinit();
    }
    {
        var acct_it = brokerage_accounts.iterator();
        while (acct_it.next()) |kv| {
            const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items);
            try consolidated_accounts.put(kv.key_ptr.*, consolidated);
        }
    }

    // For each brokerage account, find the matching portfolio account and compare
    var acct_iter = consolidated_accounts.iterator();
    while (acct_iter.next()) |kv| {
        const acct_num = kv.key_ptr.*;
        const broker_positions = kv.value_ptr.items;
        if (broker_positions.len == 0) continue;

        const broker_name = broker_positions[0].account_name;
        const portfolio_acct_name = account_map.findByInstitutionAccount(institution, acct_num);

        var comparisons = std.ArrayList(SymbolComparison).empty;
        errdefer comparisons.deinit(allocator);

        var portfolio_total: f64 = 0;
        var brokerage_total: f64 = 0;
        var option_value_delta: f64 = 0;
        var has_discrepancies = false;
        // Did the export carry a cash row for this account? A broker drops a
        // money-market position from the export entirely once it reaches
        // zero, and the comparison below is driven by brokerage rows - so
        // without this we would never look at the account's cash at all.
        var saw_cash_row = false;

        // Track which portfolio symbols we've matched
        var matched_symbols = std.StringHashMap(void).init(allocator);
        defer matched_symbols.deinit();

        // Compare each brokerage position against portfolio
        for (broker_positions) |bp| {
            const bp_value = bp.current_value orelse 0;
            brokerage_total += bp_value;

            if (portfolio_acct_name == null) {
                const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
                try comparisons.append(allocator, .{
                    .symbol = bp.symbol,
                    .portfolio_shares = 0,
                    .brokerage_shares = bp.quantity,
                    .portfolio_price = null,
                    .brokerage_price = br_price,
                    .portfolio_value = 0,
                    .brokerage_value = bp.current_value,
                    .shares_delta = if (bp.quantity) |q| q else null,
                    .value_delta = bp.current_value,
                    .is_cash = bp.is_cash,
                    .is_option = false,
                    .only_in_brokerage = true,
                    .only_in_portfolio = false,
                });
                has_discrepancies = true;
                continue;
            }

            // Sum portfolio lots for this symbol+account
            var pf_shares: f64 = 0;
            var pf_value: f64 = 0;
            var pf_price: ?f64 = null;
            var is_option = false;
            var is_cd = false;
            var cd_allowance: f64 = 0;

            if (bp.is_cash) {
                saw_cash_row = true;
                pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
                pf_value = pf_shares;
            } else {
                const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
                defer allocator.free(acct_positions);

                var found_stock = false;
                for (acct_positions) |pos| {
                    if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
                        !std.mem.eql(u8, pos.lot_symbol, bp.symbol))
                        continue;
                    pf_shares = pos.shares;
                    const v = resolvePositionValue(pos, prices);
                    pf_price = v.price;
                    pf_value = v.value;
                    try matched_symbols.put(pos.symbol, {});
                    try matched_symbols.put(pos.lot_symbol, {});
                    found_stock = true;
                }

                if (!found_stock) {
                    for (portfolio.lots) |lot| {
                        const lot_acct = lot.account orelse continue;
                        if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue;
                        if (!lot.isOpen(as_of)) continue;
                        // Match by exact symbol, or by parsed option components
                        // (brokers export a compact symbol like "-AMZN260515C220"
                        //  while the portfolio uses "AMZN 05/15/2026 220.00 C")
                        if (!std.mem.eql(u8, lot.symbol, bp.symbol) and
                            !option.symbolMatchesLot(bp.symbol, lot)) continue;
                        switch (lot.security_type) {
                            .cd => {
                                pf_shares += lot.shares;
                                pf_value += lot.shares;
                                pf_price = 1.0;
                                is_cd = true;
                                cd_allowance += cdLotAllowance(lot, as_of);
                            },
                            .option => {
                                pf_shares += lot.shares;
                                pf_value += @abs(lot.shares) * lot.open_price * lot.multiplier;
                                pf_price = lot.open_price * lot.multiplier;
                                is_option = true;
                            },
                            else => {},
                        }
                        // Track the lot's own symbol so the portfolio-only pass skips it
                        try matched_symbols.put(lot.symbol, {});
                    }
                    if (pf_shares != 0) try matched_symbols.put(bp.symbol, {});
                }
            }

            try matched_symbols.put(bp.symbol, {});
            portfolio_total += pf_value;

            const shares_delta = if (bp.quantity) |bq| bq - pf_shares else null;
            const value_delta = if (bp.current_value) |bv| bv - pf_value else null;

            const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true;
            // Cash matches to the penny; securities get $1 of NAV-rounding slack.
            const tol: f64 = if (bp.is_cash) cash_tolerance else value_tolerance;
            const value_match = if (value_delta) |d| @abs(d) < tol else true;

            // Option value deltas are expected (cost basis vs mark-to-market)
            // - track them separately rather than flagging as discrepancies
            if (is_option) {
                if (value_delta) |d| option_value_delta += d;
                if (!shares_match) has_discrepancies = true;
            } else {
                if (!shares_match or !value_match) has_discrepancies = true;
            }

            const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;

            try comparisons.append(allocator, .{
                .symbol = bp.symbol,
                .portfolio_shares = pf_shares,
                .brokerage_shares = bp.quantity,
                .portfolio_price = pf_price,
                .brokerage_price = br_price,
                .portfolio_value = pf_value,
                .brokerage_value = bp.current_value,
                .shares_delta = shares_delta,
                .value_delta = value_delta,
                .is_cash = bp.is_cash,
                .is_option = is_option,
                .is_cd = is_cd,
                .cd_allowance = cd_allowance,
                .only_in_brokerage = pf_shares == 0 and pf_value == 0,
                .only_in_portfolio = false,
            });
        }

        // Find portfolio-only positions (in portfolio but not in brokerage)
        if (portfolio_acct_name) |pa| {
            const acct_positions = portfolio.positionsForAccount(as_of, allocator, pa) catch &.{};
            defer allocator.free(acct_positions);

            for (acct_positions) |pos| {
                if (matched_symbols.contains(pos.symbol)) continue;
                if (matched_symbols.contains(pos.lot_symbol)) continue;

                try matched_symbols.put(pos.symbol, {});

                const v = resolvePositionValue(pos, prices);
                const mv = v.value;
                portfolio_total += mv;

                has_discrepancies = true;
                try comparisons.append(allocator, .{
                    .symbol = pos.symbol,
                    .portfolio_shares = pos.shares,
                    .brokerage_shares = null,
                    .portfolio_price = v.price,
                    .brokerage_price = null,
                    .portfolio_value = mv,
                    .brokerage_value = null,
                    .shares_delta = null,
                    .value_delta = null,
                    .is_cash = false,
                    .is_option = false,
                    .only_in_brokerage = false,
                    .only_in_portfolio = true,
                });
            }

            // Portfolio-only CDs and options
            for (portfolio.lots) |lot| {
                const lot_acct = lot.account orelse continue;
                if (!std.mem.eql(u8, lot_acct, pa)) continue;
                if (!lot.isOpen(as_of)) continue;
                if (lot.security_type != .cd and lot.security_type != .option) continue;
                if (matched_symbols.contains(lot.symbol)) continue;

                try matched_symbols.put(lot.symbol, {});

                var pf_shares: f64 = 0;
                var pf_value: f64 = 0;
                var pf_price: ?f64 = null;
                var is_cd = false;

                // Aggregate all lots with same symbol in this account
                for (portfolio.lots) |lot2| {
                    const la2 = lot2.account orelse continue;
                    if (!std.mem.eql(u8, la2, pa)) continue;
                    if (!lot2.isOpen(as_of)) continue;
                    if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue;
                    switch (lot2.security_type) {
                        .cd => {
                            pf_shares += lot2.shares;
                            pf_value += lot2.shares;
                            pf_price = 1.0;
                            is_cd = true;
                        },
                        .option => {
                            pf_shares += lot2.shares;
                            pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier;
                            pf_price = lot2.open_price * lot2.multiplier;
                        },
                        else => {},
                    }
                }

                if (pf_value != 0 or pf_shares != 0) {
                    portfolio_total += pf_value;
                    has_discrepancies = true;
                    try comparisons.append(allocator, .{
                        .symbol = lot.symbol,
                        .portfolio_shares = pf_shares,
                        .brokerage_shares = null,
                        .portfolio_price = pf_price,
                        .brokerage_price = null,
                        .portfolio_value = pf_value,
                        .brokerage_value = null,
                        .shares_delta = null,
                        .value_delta = null,
                        .is_cash = is_cd,
                        .is_option = !is_cd,
                        .is_cd = is_cd,
                        .only_in_brokerage = false,
                        .only_in_portfolio = true,
                    });
                }
            }

            // Cash the broker no longer reports.
            //
            // A money-market position is dropped from the export once it
            // reaches zero, so an account can appear with holdings but no cash
            // row. Because the comparison above iterates brokerage rows, "no
            // row" silently meant "no comparison" - a portfolio cash lot could
            // sit stale indefinitely while a stock or CD in the same situation
            // was flagged by the sweeps above. Observed in the wild: a $39.08
            // BrokerageLink cash lot survived three weekly reconciliations
            // after the money had actually been invested.
            //
            // This reports the discrepancy; it deliberately does not assert
            // the balance is zero. Absence is strong evidence, not proof, and
            // `audit` exists to surface things for a human to decide.
            if (!saw_cash_row) {
                const pf_cash = portfolio.cashForAccount(pa);
                if (@abs(pf_cash) > cash_tolerance) {
                    portfolio_total += pf_cash;
                    has_discrepancies = true;
                    try comparisons.append(allocator, .{
                        .symbol = "CASH",
                        .portfolio_shares = pf_cash,
                        .brokerage_shares = null,
                        .portfolio_price = 1.0,
                        .brokerage_price = null,
                        .portfolio_value = pf_cash,
                        .brokerage_value = null,
                        .shares_delta = null,
                        .value_delta = null,
                        .is_cash = true,
                        .is_option = false,
                        .only_in_brokerage = false,
                        .only_in_portfolio = true,
                    });
                }
            }
        }

        try results.append(allocator, .{
            .account_name = portfolio_acct_name orelse "",
            .brokerage_name = broker_name,
            .account_number = acct_num,
            .comparisons = try comparisons.toOwnedSlice(allocator),
            .portfolio_total = portfolio_total,
            .brokerage_total = brokerage_total,
            .total_delta = brokerage_total - portfolio_total,
            .option_value_delta = option_value_delta,
            .has_discrepancies = has_discrepancies,
        });
    }

    return results.toOwnedSlice(allocator);
}

/// Check if any account comparison results have discrepancies.
pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
    for (results) |r| {
        if (r.has_discrepancies) return true;
    }
    return false;
}

// ── Portfolio accounts absent from the export ────────────────

/// A portfolio account that maps to the institution under audit and
/// still holds open lots as-of, but whose account number never
/// appeared in the brokerage export. Surfaced as an advisory so an
/// account that was dropped from the download (or simply never
/// exported) doesn't reconcile silently. See `findAbsentAccounts`.
pub const AbsentAccount = struct {
    /// Portfolio account name. Borrows from the account map.
    account_name: []const u8,
    /// Mapped account number. Borrows from the account map.
    account_number: []const u8,
    /// Current value of the account's open holdings as-of, for context.
    portfolio_total: f64,
};

/// Collect the account numbers carried by a set of comparison
/// results. Monomorphized over the known result types that expose an
/// `account_number` field (`AccountComparison`, `SchwabAccountComparison`)
/// so the same membership input feeds `findAbsentAccounts` regardless
/// of which reconciler produced the results. Caller owns the slice;
/// the elements borrow from `results`.
pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: []const T) ![][]const u8 {
    var nums: std.ArrayList([]const u8) = .empty;
    errdefer nums.deinit(allocator);
    for (results) |r| try nums.append(allocator, r.account_number);
    return nums.toOwnedSlice(allocator);
}

/// Find portfolio accounts mapped to `institution` that still hold
/// open lots as-of but whose account number is absent from
/// `present_numbers` (the numbers that appeared in the brokerage
/// export).
///
/// This closes a long-standing asymmetry: `compareAccounts` and
/// `compareSchwabSummary` walk export -> portfolio only, so an account
/// you hold that the export dropped (forgotten in the download, or
/// silently removed by the broker) reconciles to nothing and the
/// audit says nothing. Walking the other direction here surfaces it.
///
/// Gating: only entries whose `institution` matches are considered, so
/// a Fidelity export never flags Schwab accounts. Suppression:
/// fully-closed / zero-balance accounts (no open lots as-of) are
/// skipped - a dropped account is only actionable if you still hold
/// something in it. Entries with no `account_number` are skipped too:
/// without a number they can't be matched to an export row anyway.
///
/// Caller owns the returned slice. The string fields borrow from
/// `account_map`, which must outlive the result.
pub fn findAbsentAccounts(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    account_map: analysis.AccountMap,
    institution: []const u8,
    present_numbers: []const []const u8,
    prices: std.StringHashMap(f64),
    as_of: Date,
) ![]AbsentAccount {
    var results: std.ArrayList(AbsentAccount) = .empty;
    errdefer results.deinit(allocator);

    for (account_map.entries) |e| {
        const inst = e.institution orelse continue;
        if (!std.mem.eql(u8, inst, institution)) continue;
        const num = e.account_number orelse continue;

        // Present in the export? The export -> portfolio pass covered it.
        var present = false;
        for (present_numbers) |pn| {
            if (std.mem.eql(u8, pn, num)) {
                present = true;
                break;
            }
        }
        if (present) continue;

        // Nothing held as-of -> nothing to reconcile. Suppress.
        if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue;

        try results.append(allocator, .{
            .account_name = e.account,
            .account_number = num,
            .portfolio_total = portfolio.totalForAccount(as_of, allocator, e.account, prices),
        });
    }

    return results.toOwnedSlice(allocator);
}

// ── Ratio suggestions ────────────────────────────────────────

/// A suggested `price_ratio` update for a portfolio.srf lot, derived
/// from the brokerage NAV vs the configured ratio. Pure data; the
/// string fields borrow from `portfolio` / `results`. The audit
/// renderers and finrev both consume these; formatting lives at the
/// call site, not here.
pub const RatioSuggestion = struct {
    account_name: []const u8,
    lot_symbol: []const u8,
    price_symbol: []const u8,
    current_ratio: f64,
    suggested_ratio: f64,
    drift_pct: f64,
};

/// Suggest `price_ratio` updates from per-account comparisons.
///
/// Institutional share class: `suggested = brokerage_NAV / retail_price`
/// against the configured lot ratio. Normally only lots with
/// `price_ratio != 1.0` qualify; direct-indexing accounts (flagged in
/// `accounts.srf`) bypass that gate, since their tracking-error drift is
/// expressed by nudging a ratio that starts at 1.0.
///
/// Skips unmatched / cash / option rows, non-stock lots, symbol
/// mismatches, missing or zero retail prices, and no-drift lots
/// (`current == suggested`). At most one suggestion per comparison row.
/// Caller owns the returned slice; strings borrow from the inputs.
pub fn ratioSuggestions(
    allocator: std.mem.Allocator,
    results: []const AccountComparison,
    portfolio: zfin.Portfolio,
    prices: std.StringHashMap(f64),
    account_map: ?analysis.AccountMap,
) ![]RatioSuggestion {
    var out: std.ArrayList(RatioSuggestion) = .empty;
    errdefer out.deinit(allocator);

    for (results) |acct| {
        for (acct.comparisons) |cmp| {
            if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
            if (cmp.is_cash or cmp.is_option) continue;

            const is_direct_indexing = if (account_map) |am|
                am.isDirectIndexing(acct.account_name)
            else
                false;

            for (portfolio.lots) |lot| {
                if (lot.price_ratio == 1.0 and !is_direct_indexing) continue;
                if (lot.security_type != .stock) continue;
                const lot_acct = lot.account orelse continue;
                if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue;

                const lot_sym = lot.symbol;
                const price_sym = lot.priceSymbol();
                if (!std.mem.eql(u8, lot_sym, cmp.symbol) and
                    !std.mem.eql(u8, price_sym, cmp.symbol)) continue;

                const retail_price = prices.get(price_sym) orelse continue;
                const inst_nav = cmp.brokerage_price orelse continue;
                if (retail_price == 0) continue;

                const current_ratio = lot.price_ratio;
                const suggested_ratio = inst_nav / retail_price;
                if (current_ratio == suggested_ratio) break; // no drift

                try out.append(allocator, .{
                    .account_name = acct.account_name,
                    .lot_symbol = lot_sym,
                    .price_symbol = price_sym,
                    .current_ratio = current_ratio,
                    .suggested_ratio = suggested_ratio,
                    .drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0,
                });
                break; // one suggestion per comparison row
            }
        }
    }

    return out.toOwnedSlice(allocator);
}

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

test "consolidateBySymbol: distinct symbols pass through unchanged" {
    const allocator = std.testing.allocator;
    const rows = [_]BrokeragePosition{
        .{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "", .quantity = 39, .current_value = 10300, .cost_basis = 10000, .is_cash = false },
        .{ .account_number = "A", .account_name = "Acct", .symbol = "QTUM", .description = "", .quantity = 100, .current_value = 14000, .cost_basis = 13000, .is_cash = false },
    };
    var out = try consolidateBySymbol(allocator, &rows);
    defer out.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 2), out.items.len);
    try std.testing.expectEqualStrings("AMZN", out.items[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 39), out.items[0].quantity.?, 0.01);
    try std.testing.expectEqualStrings("QTUM", out.items[1].symbol);
}

test "consolidateBySymbol: same-symbol rows aggregate quantity and value" {
    // Reproduces the Fidelity Cash + Margin double-row scenario
    // (newly-credited shares pre-settlement live in the cash
    // sub-account; older settled shares live in the margin
    // sub-account; both rows share the ticker). Both rows are
    // AMZN in the same account; consolidation must sum to one
    // entry of 40 shares total.
    const allocator = std.testing.allocator;
    const rows = [_]BrokeragePosition{
        .{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "Cash row", .quantity = 39, .current_value = 10301.46, .cost_basis = 10244.55, .is_cash = false },
        .{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "Margin row", .quantity = 1, .current_value = 264.14, .cost_basis = null, .is_cash = false },
    };
    var out = try consolidateBySymbol(allocator, &rows);
    defer out.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), out.items.len);
    try std.testing.expectEqualStrings("AMZN", out.items[0].symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 40), out.items[0].quantity.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 10565.60), out.items[0].current_value.?, 0.01);
    // cost_basis was null on the margin row but present on cash row;
    // null + value = value preserves the cash row's basis.
    try std.testing.expectApproxEqAbs(@as(f64, 10244.55), out.items[0].cost_basis.?, 0.01);
    try std.testing.expect(!out.items[0].is_cash);
}

test "consolidateBySymbol: null quantities collapse to null sum" {
    // Two cash rows for the same money-market symbol - Fidelity reports
    // these with quantity null and a dollar value. Sum the values, leave
    // quantity null.
    const allocator = std.testing.allocator;
    const rows = [_]BrokeragePosition{
        .{ .account_number = "A", .account_name = "Acct", .symbol = "FZFXX", .description = "", .quantity = null, .current_value = 100, .cost_basis = null, .is_cash = true },
        .{ .account_number = "A", .account_name = "Acct", .symbol = "FZFXX", .description = "", .quantity = null, .current_value = 50, .cost_basis = null, .is_cash = true },
    };
    var out = try consolidateBySymbol(allocator, &rows);
    defer out.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), out.items.len);
    try std.testing.expectEqual(@as(?f64, null), out.items[0].quantity);
    try std.testing.expectApproxEqAbs(@as(f64, 150), out.items[0].current_value.?, 0.01);
    try std.testing.expect(out.items[0].is_cash);
}

test "consolidateBySymbol: empty input returns empty" {
    const allocator = std.testing.allocator;
    const rows = [_]BrokeragePosition{};
    var out = try consolidateBySymbol(allocator, &rows);
    defer out.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), out.items.len);
}

// ── resolvePositionValue ──────────────────────────────────────
//
// Pins the audit-side price-provenance rule: live-from-cache prices
// get price_ratio applied; avg_cost-fallback prices do not. This
// closes the latent bug where institutional-share-class positions
// (price_ratio != 1.0) that missed the cache would have their value
// over-reported by the ratio factor.

test "resolvePositionValue: live cache hit applies price_ratio" {
    const allocator = std.testing.allocator;
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VTTHX", 27.78); // retail-class close

    const pos: zfin.Position = .{
        .symbol = "VTTHX",
        .lot_symbol = "VTTHX",
        .shares = 100,
        .avg_cost = 106.18,
        .total_cost = 10618,
        .open_lots = 1,
        .closed_lots = 0,
        .realized_gain_loss = 0,
        .account = "401k",
        .price_ratio = 5.185,
    };

    const v = resolvePositionValue(pos, prices);
    // price_ratio applied: 27.78 * 5.185 = 144.04
    try std.testing.expectApproxEqAbs(@as(f64, 144.04), v.price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 14403.93), v.value, 0.01);
}

test "resolvePositionValue: avg_cost fallback skips price_ratio" {
    const allocator = std.testing.allocator;
    // Empty prices map - simulate cache miss for VTTHX.
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const pos: zfin.Position = .{
        .symbol = "VTTHX",
        .lot_symbol = "VTTHX",
        .shares = 100,
        .avg_cost = 106.18, // already institutional-class terms
        .total_cost = 10618,
        .open_lots = 1,
        .closed_lots = 0,
        .realized_gain_loss = 0,
        .account = "401k",
        .price_ratio = 5.185,
    };

    const v = resolvePositionValue(pos, prices);
    // Pre-fix behavior would have multiplied: 106.18 * 5.185 = 550.55.
    // Correct behavior: avg_cost is already in lot share-class terms.
    try std.testing.expectApproxEqAbs(@as(f64, 106.18), v.price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 10618.0), v.value, 0.01);
}

test "resolvePositionValue: ratio-1.0 position unaffected by provenance" {
    // Sanity: when price_ratio == 1.0, the bug never fired. Both paths
    // should give the same answer.
    const allocator = std.testing.allocator;
    var prices_hit = std.StringHashMap(f64).init(allocator);
    defer prices_hit.deinit();
    try prices_hit.put("AAPL", 200.0);

    var prices_miss = std.StringHashMap(f64).init(allocator);
    defer prices_miss.deinit();

    const pos: zfin.Position = .{
        .symbol = "AAPL",
        .lot_symbol = "AAPL",
        .shares = 10,
        .avg_cost = 150.0,
        .total_cost = 1500,
        .open_lots = 1,
        .closed_lots = 0,
        .realized_gain_loss = 0,
        .account = "Roth",
    };

    const hit = resolvePositionValue(pos, prices_hit);
    const miss = resolvePositionValue(pos, prices_miss);

    try std.testing.expectApproxEqAbs(@as(f64, 200.0), hit.price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 2000.0), hit.value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 150.0), miss.price, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 1500.0), miss.value, 0.01);
}

test "option delta tracking in compareAccounts" {
    const allocator = std.testing.allocator;

    // Build a minimal portfolio with an option lot
    var lots = [_]portfolio_mod.Lot{
        .{
            .symbol = "MSFT 05/15/2026 400.00 C",
            .security_type = .option,
            .underlying = "MSFT",
            .strike = 400.0,
            .option_type = .call,
            .maturity_date = Date.fromYmd(2026, 5, 15),
            .shares = -2,
            .open_date = Date.fromYmd(2025, 1, 1),
            .open_price = 6.68,
            .multiplier = 100,
            .account = "Sample IRA",
        },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    // Brokerage shows the option at different (mark-to-market) value
    var brokerage = [_]BrokeragePosition{
        .{
            .account_number = "1234",
            .account_name = "SCHWAB 1234",
            .symbol = "MSFT 05/15/2026 400.00 C",
            .description = "MSFT CALL",
            .quantity = -2,
            .current_value = -6511.20,
            .cost_basis = -1336.0,
            .is_cash = false,
        },
    };

    // Account map: map schwab account 1234 -> portfolio "Sample IRA"
    var entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Sample IRA",
            .tax_type = .roth,
            .institution = "schwab",
            .account_number = "1234",
        },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 5, 8));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    const acct = results[0];

    // Option should be matched, with option_value_delta tracking the difference
    try std.testing.expect(@abs(acct.option_value_delta) > 1.0);
    // Option value mismatch should NOT set has_discrepancies
    try std.testing.expect(!acct.has_discrepancies);

    // The comparison should be flagged as is_option
    var found_option = false;
    for (acct.comparisons) |cmp| {
        if (cmp.is_option) {
            found_option = true;
            // Shares should match (-2 vs -2)
            if (cmp.shares_delta) |d| {
                try std.testing.expect(@abs(d) < 0.01);
            }
        }
    }
    try std.testing.expect(found_option);
}

test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" {
    const allocator = std.testing.allocator;

    // Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22
    // money-market dividend accrual. It's below the $1 securities
    // tolerance, but cash carries no NAV rounding, so it must match to
    // the penny rather than be silently swallowed.
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "FDRXX", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 38.97, .cost_basis = null, .is_cash = true },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expect(results[0].has_discrepancies);

    var found_cash = false;
    for (results[0].comparisons) |cmp| {
        if (cmp.is_cash) {
            found_cash = true;
            try std.testing.expectApproxEqAbs(@as(f64, 0.22), cmp.value_delta.?, 0.001);
        }
    }
    try std.testing.expect(found_cash);
}

test "hasAccountDiscrepancies" {
    const clean = [_]AccountComparison{.{
        .account_name = "Acct",
        .brokerage_name = "Schwab",
        .account_number = "123",
        .comparisons = &.{},
        .portfolio_total = 1000,
        .brokerage_total = 1000,
        .total_delta = 0,
        .option_value_delta = 0,
        .has_discrepancies = false,
    }};
    try std.testing.expect(!hasAccountDiscrepancies(&clean));

    const dirty = [_]AccountComparison{.{
        .account_name = "Acct",
        .brokerage_name = "Schwab",
        .account_number = "123",
        .comparisons = &.{},
        .portfolio_total = 1000,
        .brokerage_total = 1100,
        .total_delta = 100,
        .option_value_delta = 0,
        .has_discrepancies = true,
    }};
    try std.testing.expect(hasAccountDiscrepancies(&dirty));
}

// ── compareAccounts: branch coverage ─────────────────────────
//
// The two pre-existing compareAccounts tests cover option-delta
// tracking and sub-dollar cash drift. These pin the remaining
// structural branches: the unmapped-account path, the portfolio-only
// passes (stock, CD, option), and the CD-matched-to-broker-row path.

test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
    const allocator = std.testing.allocator;

    // account_map has no entry for account "9999" -> findByInstitutionAccount
    // returns null -> the portfolio_acct_name == null branch fires.
    const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
    var entries = [_]analysis.AccountTaxEntry{};
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "9999", .account_name = "FIDELITY 9999", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
    };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    // Unmapped -> account_name resolves to "" via `orelse`.
    try std.testing.expectEqualStrings("", results[0].account_name);
    try std.testing.expect(results[0].has_discrepancies);
    try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
    const cmp = results[0].comparisons[0];
    try std.testing.expect(cmp.only_in_brokerage);
    try std.testing.expectEqualStrings("AAPL", cmp.symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].brokerage_total, 0.01);
    // brokerage-only row carries a derived per-share price (value/qty).
    try std.testing.expectApproxEqAbs(@as(f64, 200), cmp.brokerage_price.?, 0.01);
}

test "compareAccounts: cash the broker no longer reports is flagged portfolio-only" {
    const allocator = std.testing.allocator;

    // The real BrokerageLink incident. The $39.08 money-market balance had
    // already been invested, so Fidelity dropped the FDRXX row from the
    // export entirely - a zero position isn't exported. The account still
    // appears (it holds FDSCX), but with no cash row. Because the comparison
    // is driven by brokerage rows, "no row" used to mean "no comparison", and
    // the stale cash lot survived three weekly reconciliations unnoticed.
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" },
        .{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    // Export carries the holding but NO cash row.
    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("FDSCX", 50.67);

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expect(results[0].has_discrepancies);

    var found = false;
    for (results[0].comparisons) |cmp| {
        if (!cmp.is_cash or !cmp.only_in_portfolio) continue;
        found = true;
        try std.testing.expectApproxEqAbs(@as(f64, 39.08), cmp.portfolio_value, 0.001);
        try std.testing.expect(cmp.brokerage_value == null);
        try std.testing.expect(cmp.brokerage_shares == null);
    }
    try std.testing.expect(found);
}

test "compareAccounts: an export cash row suppresses the portfolio-only cash flag" {
    const allocator = std.testing.allocator;

    // Same account, but the broker DOES report cash. The normal comparison
    // path owns it; there must be no duplicate portfolio-only cash row.
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 39.08, .cost_basis = null, .is_cash = true },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    for (results[0].comparisons) |cmp| {
        try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
    }
    try std.testing.expect(!results[0].has_discrepancies);
}

test "compareAccounts: no cash row and no portfolio cash stays silent" {
    const allocator = std.testing.allocator;

    // Absence on both sides is agreement, not a discrepancy.
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("FDSCX", 50.67);

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    for (results[0].comparisons) |cmp| {
        try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
    }
}

test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" {
    const allocator = std.testing.allocator;

    // Two stocks in the account; the broker export lists only AAPL,
    // so MSFT must surface as portfolio-only.
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
        .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300, .account = "Sample IRA" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
    };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);
    try prices.put("MSFT", 320.0);

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 6, 19));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    var found_msft_only = false;
    for (results[0].comparisons) |cmp| {
        if (std.mem.eql(u8, cmp.symbol, "MSFT")) {
            try std.testing.expect(cmp.only_in_portfolio);
            try std.testing.expect(!cmp.only_in_brokerage);
            found_msft_only = true;
        }
    }
    try std.testing.expect(found_msft_only);
    try std.testing.expect(results[0].has_discrepancies);
}

test "compareAccounts: portfolio-only CD and option lots are flagged" {
    const allocator = std.testing.allocator;

    var lots = [_]portfolio_mod.Lot{
        // Matched stock so the account gets processed at all.
        .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
        // CD not present in the broker export -> portfolio-only CD path.
        .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
        // Option not present in the broker export -> portfolio-only option path.
        .{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
    };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("AAPL", 200.0);

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    var found_cd = false;
    var found_opt = false;
    for (results[0].comparisons) |cmp| {
        if (std.mem.eql(u8, cmp.symbol, "CD-1234")) {
            try std.testing.expect(cmp.only_in_portfolio);
            try std.testing.expect(cmp.is_cash); // CDs render as cash-class rows
            try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01);
            found_cd = true;
        }
        if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) {
            try std.testing.expect(cmp.only_in_portfolio);
            try std.testing.expect(cmp.is_option);
            // |-2| * 8.75 * 100 = 1750
            try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01);
            found_opt = true;
        }
    }
    try std.testing.expect(found_cd);
    try std.testing.expect(found_opt);
}

test "compareAccounts: a broker CD row matches a portfolio CD lot" {
    const allocator = std.testing.allocator;

    // CD present on both sides with identical value -> exercises the
    // `.cd` arm of the lot-match switch and lands on no discrepancy.
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CD-1234", .description = "BANK CD", .quantity = 10000, .current_value = 10000, .cost_basis = 10000, .is_cash = false },
    };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
    const cmp = results[0].comparisons[0];
    try std.testing.expectEqualStrings("CD-1234", cmp.symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), cmp.portfolio_price.?, 0.01);
    try std.testing.expect(!results[0].has_discrepancies);
}

test "cdLotAllowance: near-maturity CD yields a tight band" {
    const as_of = Date.fromYmd(2026, 6, 28);
    const cd = portfolio_mod.Lot{
        .symbol = "CDNEAR",
        .security_type = .cd,
        .shares = 87000,
        .open_date = Date.fromYmd(2026, 2, 25),
        .open_price = 1.0,
        .rate = 3.8,
        .maturity_date = Date.fromYmd(2026, 7, 15),
        .account = "Sample IRA",
    };
    // 17 days out: 87000 * 3.8% * (17/365.25) ~= $154. Tight enough to
    // mute a dealer mark, far below a missing lot.
    const band = cdLotAllowance(cd, as_of);
    try std.testing.expect(band > 100 and band < 200);
}

test "cdLotAllowance: long CD is capped at one year's coupon" {
    const as_of = Date.fromYmd(2026, 6, 28);
    const cd = portfolio_mod.Lot{
        .symbol = "CD5YR",
        .security_type = .cd,
        .shares = 100000,
        .open_date = Date.fromYmd(2026, 1, 1),
        .open_price = 1.0,
        .rate = 4.0,
        .maturity_date = Date.fromYmd(2031, 6, 28),
        .account = "Sample IRA",
    };
    // ~5 years out, but years clamp to 1.0 -> 100000 * 4% * 1.0 = 4000.
    try std.testing.expectApproxEqAbs(@as(f64, 4000), cdLotAllowance(cd, as_of), 0.01);
}

test "cdLotAllowance: rate-less CD falls back to a small percent of face" {
    const as_of = Date.fromYmd(2026, 6, 28);
    const cd = portfolio_mod.Lot{
        .symbol = "CDNORATE",
        .security_type = .cd,
        .shares = 10000,
        .open_date = Date.fromYmd(2026, 1, 1),
        .open_price = 1.0,
        .maturity_date = Date.fromYmd(2026, 12, 31),
        .account = "Sample IRA",
    };
    // No rate -> 0.5% of $10,000 = $50.
    try std.testing.expectApproxEqAbs(@as(f64, 50), cdLotAllowance(cd, as_of), 0.01);
}

test "cdLotAllowance: matured CD and non-CD lots yield zero" {
    const as_of = Date.fromYmd(2026, 6, 28);
    // Maturity already passed -> years clamp to 0 -> no band.
    const matured = portfolio_mod.Lot{
        .symbol = "CDOLD",
        .security_type = .cd,
        .shares = 50000,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 1.0,
        .rate = 5.0,
        .maturity_date = Date.fromYmd(2026, 1, 1),
        .account = "Sample IRA",
    };
    try std.testing.expectApproxEqAbs(@as(f64, 0), cdLotAllowance(matured, as_of), 0.01);

    const stock = portfolio_mod.Lot{
        .symbol = "AAPL",
        .shares = 10,
        .open_date = Date.fromYmd(2024, 1, 1),
        .open_price = 150,
        .account = "Sample Brokerage",
    };
    try std.testing.expectEqual(@as(f64, 0), cdLotAllowance(stock, as_of));
}

test "accountValueExpectation: CDs sum into a band, an open option flips the flag" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 28);
    var lots = [_]portfolio_mod.Lot{
        // CD in Sample IRA, >1yr out so it caps at one year's coupon: 4% of 50000 = 2000.
        .{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" },
        // Open option in Sample IRA -> has_options.
        .{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" },
        // Plain stock in a different account.
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample Brokerage" },
        // Matured CD in Sample IRA must be excluded (not open as-of).
        .{ .symbol = "CDMAT", .security_type = .cd, .shares = 99999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .rate = 9.0, .maturity_date = Date.fromYmd(2026, 1, 1), .account = "Sample IRA" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    const ira = accountValueExpectation(portfolio, as_of, "Sample IRA");
    try std.testing.expect(ira.has_options);
    try std.testing.expectApproxEqAbs(@as(f64, 2000), ira.cd_allowance, 0.01);

    const brk = accountValueExpectation(portfolio, as_of, "Sample Brokerage");
    try std.testing.expect(!brk.has_options);
    try std.testing.expectEqual(@as(f64, 0), brk.cd_allowance);
}

test "compareAccounts: a CD's value delta carries is_cd and the bounded allowance" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 28);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "CDX", .security_type = .cd, .shares = 100000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2026, 9, 1), .account = "Sample IRA" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // Broker marks the CD $40 under face (~2mo out -> band is hundreds of $).
    var brokerage = [_]BrokeragePosition{
        .{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CDX", .description = "BANK CD", .quantity = 100000, .current_value = 99960, .cost_basis = 100000, .is_cash = false },
    };
    const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, as_of);
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }
    try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
    const cmp = results[0].comparisons[0];
    try std.testing.expect(cmp.is_cd);
    try std.testing.expect(cmp.cd_allowance > 200); // the $40 mark sits well within
    try std.testing.expectApproxEqAbs(@as(f64, -40), cmp.value_delta.?, 0.01);
}

// ── Absent-account detection ─────────────────────────────────

test "presentNumbers: collects account_number from each result" {
    const allocator = std.testing.allocator;

    const results = [_]AccountComparison{
        .{ .account_name = "Sample IRA", .brokerage_name = "Fid", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
        .{ .account_name = "Sample Brokerage", .brokerage_name = "Fid", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
    };

    const nums = try presentNumbers(allocator, AccountComparison, &results);
    defer allocator.free(nums);

    try std.testing.expectEqual(@as(usize, 2), nums.len);
    try std.testing.expectEqualStrings("1234", nums[0]);
    try std.testing.expectEqualStrings("5678", nums[1]);
}

test "findAbsentAccounts: flags held account missing from export; honors gating + closed-account suppression" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 19);

    var lots = [_]portfolio_mod.Lot{
        // fidelity #1234 -> held, absent from export -> SHOULD flag.
        .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
        // fidelity #5678 -> held, present in export -> handled by main pass.
        .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" },
        // fidelity #3456 -> only a closed lot, absent -> suppressed.
        .{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" },
        // schwab #9012 -> held, absent, but wrong institution for a Fidelity audit.
        .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
        .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" },
        .{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" },
        .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
        // institution set but no account number -> can't match an export row -> skipped.
        .{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VTI", 210.0);

    // The Fidelity export contained only account #5678.
    const present = [_][]const u8{"5678"};

    const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of);
    defer allocator.free(absent);

    try std.testing.expectEqual(@as(usize, 1), absent.len);
    try std.testing.expectEqualStrings("Sample IRA", absent[0].account_name);
    try std.testing.expectEqualStrings("1234", absent[0].account_number);
    try std.testing.expectApproxEqAbs(@as(f64, 2100.0), absent[0].portfolio_total, 0.01);
}

test "findAbsentAccounts: gating flags only the audited institution" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 19);

    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
        .{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
        .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    // Auditing a Schwab export that matched no known account number:
    // only the Schwab account surfaces; the Fidelity account is gated out.
    const present = [_][]const u8{};
    const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "schwab", &present, prices, as_of);
    defer allocator.free(absent);

    try std.testing.expectEqual(@as(usize, 1), absent.len);
    try std.testing.expectEqualStrings("Schwab Trust", absent[0].account_name);
    try std.testing.expectEqualStrings("9012", absent[0].account_number);
}

test "findAbsentAccounts: no absent accounts when export covers every held account" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 19);

    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
    };
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();

    const present = [_][]const u8{"1234"};
    const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of);
    defer allocator.free(absent);

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

// ── ratioSuggestions ─────────────────────────────────────────

test "ratioSuggestions: institutional NAV drift yields exact ratio + drift" {
    const allocator = std.testing.allocator;
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 5.0 },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VTHRX", 5.0); // retail close
    const cmps = [_]SymbolComparison{
        // inst NAV 30 / retail 5 = suggested 6.0 vs configured 5.0
        .{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 25.0, .brokerage_price = 30.0, .portfolio_value = 2500, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 500, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
    };
    const results = [_]AccountComparison{
        .{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 2500, .brokerage_total = 3000, .total_delta = 500, .option_value_delta = 0, .has_discrepancies = true },
    };
    const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null);
    defer allocator.free(sugg);
    try std.testing.expectEqual(@as(usize, 1), sugg.len);
    try std.testing.expectEqualStrings("VTHRX", sugg[0].lot_symbol);
    try std.testing.expectEqualStrings("VTHRX", sugg[0].price_symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 5.0), sugg[0].current_ratio, 1e-9);
    try std.testing.expectApproxEqAbs(@as(f64, 6.0), sugg[0].suggested_ratio, 1e-9);
    try std.testing.expectApproxEqAbs(@as(f64, 20.0), sugg[0].drift_pct, 1e-9);
}

test "ratioSuggestions: direct-indexing suggests at ratio 1.0; plain 1.0 does not" {
    const allocator = std.testing.allocator;
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 500.0);
    const cmps = [_]SymbolComparison{
        .{ .symbol = "SPY", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 500, .brokerage_price = 510.0, .portfolio_value = 50000, .brokerage_value = 51000, .shares_delta = 0, .value_delta = 1000, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
    };
    const results = [_]AccountComparison{
        .{ .account_name = "Sample Brokerage", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 50000, .brokerage_total = 51000, .total_delta = 1000, .option_value_delta = 0, .has_discrepancies = true },
    };
    // Plain 1.0 lot, no direct-indexing -> gated out.
    {
        const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null);
        defer allocator.free(sugg);
        try std.testing.expectEqual(@as(usize, 0), sugg.len);
    }
    // Direct-indexing bypasses the 1.0 gate: 510/500 = 1.02, +2% drift.
    {
        var entries = [_]analysis.AccountTaxEntry{
            .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
        };
        const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
        const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, am);
        defer allocator.free(sugg);
        try std.testing.expectEqual(@as(usize, 1), sugg.len);
        try std.testing.expectApproxEqAbs(@as(f64, 1.02), sugg[0].suggested_ratio, 1e-9);
        try std.testing.expectApproxEqAbs(@as(f64, 2.0), sugg[0].drift_pct, 1e-9);
    }
}

test "ratioSuggestions: zero drift produces no suggestion" {
    const allocator = std.testing.allocator;
    // Configured ratio already equals inst_nav/retail (30/5 = 6.0).
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 6.0 },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("VTHRX", 5.0);
    const cmps = [_]SymbolComparison{
        .{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 30.0, .brokerage_price = 30.0, .portfolio_value = 3000, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
    };
    const results = [_]AccountComparison{
        .{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 3000, .brokerage_total = 3000, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
    };
    const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null);
    defer allocator.free(sugg);
    try std.testing.expectEqual(@as(usize, 0), sugg.len);
}

test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
    const allocator = std.testing.allocator;
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 5.0 },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    // No price for VTHRX -> even the matching lot is skipped.
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    const cmps = [_]SymbolComparison{
        .{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 100, .brokerage_value = 100, .shares_delta = null, .value_delta = 0, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
        .{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
        .{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
        // matched non-cash row, but VTHRX has no cached retail price -> skipped
        .{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 25.0, .brokerage_price = 30.0, .portfolio_value = 2500, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 500, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
    };
    const results = [_]AccountComparison{
        .{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 4350, .brokerage_total = 6950, .total_delta = 2600, .option_value_delta = 1500, .has_discrepancies = true },
    };
    const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null);
    defer allocator.free(sugg);
    try std.testing.expectEqual(@as(usize, 0), sugg.len);
}
