//! Schwab reconcilers (pure compute).
//!
//! Schwab has two export shapes, so this module carries more than
//! the Fidelity one:
//!
//!   1. **Per-account positions CSV** (`--schwab`) - same per-account
//!      positions shape Fidelity uses, so it feeds the shared
//!      `common.compareAccounts` engine via `reconcileCsv`.
//!   2. **Account summary paste** (`--schwab-summary`) - a
//!      per-account totals-only view with no per-symbol detail. This
//!      is the one genuinely broker-specific reconciler, with its own
//!      comparison type (`SchwabAccountComparison`) and comparator
//!      (`compareSchwabSummary`).
//!
//! Parsing lives in `brokerage/schwab.zig`; the ANSI renderers live in
//! `commands/audit/schwab.zig`.

const std = @import("std");
const zfin = @import("../../root.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const schwab_parser = @import("../../brokerage/schwab.zig");

const AccountSummary = schwab_parser.AccountSummary;

/// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct {
    account_name: []const u8,
    schwab_name: []const u8,
    account_number: []const u8,
    portfolio_cash: f64,
    schwab_cash: ?f64,
    cash_delta: ?f64,
    portfolio_total: f64,
    schwab_total: ?f64,
    total_delta: ?f64,
    /// Account holds open options: zfin values them at cost, the broker
    /// at market, so a totals-level value delta is expected (mute it).
    has_options: bool = false,
    /// Bounded band within which a value delta is an expected CD mark.
    cd_allowance: f64 = 0,
    has_discrepancy: bool,
};

// ── Per-account positions CSV (--schwab) ─────────────────────

/// Parse a Schwab per-account positions CSV and reconcile it against
/// the portfolio via the shared engine. Returns owned
/// `AccountComparison` results (free each `.comparisons` slice, then
/// the results slice). String fields borrow from `csv_data`, which
/// must outlive them. Propagates parser and allocation errors.
pub fn reconcileCsv(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    csv_data: []const u8,
    account_map: analysis.AccountMap,
    prices: std.StringHashMap(f64),
    as_of: Date,
) ![]common.AccountComparison {
    const parsed = try schwab_parser.parseCsv(allocator, csv_data);
    // Result strings borrow from `csv_data`, not the positions slice,
    // so freeing the slice array here is safe.
    defer allocator.free(parsed.positions);
    return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
}

// ── Account summary paste (--schwab-summary) ─────────────────

/// Parse a Schwab account summary and reconcile its per-account
/// totals against portfolio.srf. Returns owned results (free the
/// slice). String fields borrow from `summary_data`, which must
/// outlive them. Propagates parser (`NoAccountsFound`) and
/// allocation errors.
pub fn reconcileSummary(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    summary_data: []const u8,
    account_map: analysis.AccountMap,
    prices: std.StringHashMap(f64),
    as_of: Date,
) ![]SchwabAccountComparison {
    const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
    defer allocator.free(schwab_accounts);
    return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
}

/// Compare Schwab summary against portfolio.srf account totals.
pub fn compareSchwabSummary(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    schwab_accounts: []const AccountSummary,
    account_map: analysis.AccountMap,
    prices: std.StringHashMap(f64),
    as_of: Date,
) ![]SchwabAccountComparison {
    var results = std.ArrayList(SchwabAccountComparison).empty;
    errdefer results.deinit(allocator);

    for (schwab_accounts) |sa| {
        const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);

        var pf_cash: f64 = 0;
        var pf_total: f64 = 0;
        var expectation: common.AccountValueExpectation = .{};

        if (portfolio_acct) |pa| {
            pf_cash = portfolio.cashForAccount(pa);
            pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
            expectation = common.accountValueExpectation(portfolio, as_of, pa);
        }

        const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
        const total_delta = if (sa.total_value) |st| st - pf_total else null;

        const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
        const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;

        try results.append(allocator, .{
            .account_name = portfolio_acct orelse "",
            .schwab_name = sa.account_name,
            .account_number = sa.account_number,
            .portfolio_cash = pf_cash,
            .schwab_cash = sa.cash,
            .cash_delta = cash_delta,
            .portfolio_total = pf_total,
            .schwab_total = sa.total_value,
            .total_delta = total_delta,
            .has_options = expectation.has_options,
            .cd_allowance = expectation.cd_allowance,
            // A value delta is a discrepancy regardless of cause: an
            // audit must never hide it. Muting (below) is a display
            // hint, not a suppression - so visibility stays keyed on the
            // raw tolerance, same as before.
            .has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
        });
    }

    return results.toOwnedSlice(allocator);
}

/// Check if any Schwab summary results have discrepancies.
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
    for (results) |r| {
        if (r.has_discrepancy) return true;
    }
    return false;
}

/// Direct-indexing ratio suggestions from Schwab-summary totals.
///
/// The summary path gives per-account totals only, so for a
/// direct-indexing account with exactly one stock lot we allocate the
/// whole account delta onto that lot:
///
///   current_stock_value = shares * retail_price * current_ratio
///   target_stock_value  = current_stock_value + total_delta
///   suggested_ratio     = target_stock_value / (shares * retail_price)
///
/// Skips non-direct-indexing accounts, unmapped accounts, sub-cent
/// deltas, accounts without exactly one stock lot, and zero
/// share/price/value. Caller owns the returned slice; strings borrow
/// from the inputs.
pub fn summaryRatioSuggestions(
    allocator: std.mem.Allocator,
    results: []const SchwabAccountComparison,
    portfolio: zfin.Portfolio,
    prices: std.StringHashMap(f64),
    account_map: ?analysis.AccountMap,
) ![]common.RatioSuggestion {
    var out: std.ArrayList(common.RatioSuggestion) = .empty;
    errdefer out.deinit(allocator);

    const am = account_map orelse return out.toOwnedSlice(allocator);

    for (results) |r| {
        if (r.account_name.len == 0) continue;
        if (!am.isDirectIndexing(r.account_name)) continue;
        const total_delta = r.total_delta orelse continue;
        if (@abs(total_delta) < 0.01) continue;

        // Find the single stock lot for this account.
        var stock_lot: ?zfin.Lot = null;
        var stock_lot_count: usize = 0;
        for (portfolio.lots) |lot| {
            if (lot.security_type != .stock) continue;
            const lot_acct = lot.account orelse continue;
            if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
            stock_lot = lot;
            stock_lot_count += 1;
        }
        if (stock_lot_count != 1) continue;
        const lot = stock_lot.?;

        const price_sym = lot.priceSymbol();
        const retail_price = prices.get(price_sym) orelse continue;
        if (retail_price == 0) continue;
        if (lot.shares == 0) continue;

        const current_stock_value = lot.shares * retail_price * lot.price_ratio;
        if (current_stock_value == 0) continue;
        const target_stock_value = current_stock_value + total_delta;
        const suggested_ratio = target_stock_value / (lot.shares * retail_price);

        try out.append(allocator, .{
            .account_name = r.account_name,
            .lot_symbol = lot.symbol,
            .price_symbol = price_sym,
            .current_ratio = lot.price_ratio,
            .suggested_ratio = suggested_ratio,
            .drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0,
        });
    }

    return out.toOwnedSlice(allocator);
}

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

const portfolio_mod = @import("../../models/portfolio.zig");

test "hasSchwabDiscrepancies" {
    const clean = [_]SchwabAccountComparison{.{
        .account_name = "IRA",
        .schwab_name = "Roth IRA",
        .account_number = "1234",
        .portfolio_cash = 100,
        .schwab_cash = 100,
        .cash_delta = 0,
        .portfolio_total = 5000,
        .schwab_total = 5000,
        .total_delta = 0,
        .has_discrepancy = false,
    }};
    try std.testing.expect(!hasSchwabDiscrepancies(&clean));

    const dirty = [_]SchwabAccountComparison{.{
        .account_name = "IRA",
        .schwab_name = "Roth IRA",
        .account_number = "1234",
        .portfolio_cash = 100,
        .schwab_cash = 200,
        .cash_delta = 100,
        .portfolio_total = 5000,
        .schwab_total = 5100,
        .total_delta = 100,
        .has_discrepancy = true,
    }};
    try std.testing.expect(hasSchwabDiscrepancies(&dirty));
}

test "compareSchwabSummary: matching account -> no discrepancy" {
    const allocator = std.testing.allocator;
    const today = Date.fromYmd(2026, 5, 8);

    // Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
    // With AAPL price=200, total = 5000 + 10*200 = 7000.
    const lots = [_]portfolio_mod.Lot{
        .{
            .symbol = "CASH",
            .shares = 5000,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 1.0,
            .security_type = .cash,
            .account = "Sample Brokerage",
        },
        .{
            .symbol = "AAPL",
            .shares = 10,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 150,
            .account = "Sample Brokerage",
        },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };

    const schwab_accounts = [_]AccountSummary{
        .{
            .account_name = "Sample Brokerage",
            .account_number = "1234",
            .cash = 5000.0,
            .total_value = 7000.0,
        },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Sample Brokerage",
            .tax_type = .taxable,
            .institution = "schwab",
            .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("AAPL", 200.0);

    const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
    try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
    try std.testing.expect(!results[0].has_discrepancy);
}

test "compareSchwabSummary: cash mismatch -> has_discrepancy true" {
    const allocator = std.testing.allocator;
    const today = Date.fromYmd(2026, 5, 8);

    // Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
    const lots = [_]portfolio_mod.Lot{
        .{
            .symbol = "CASH",
            .shares = 5000,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 1.0,
            .security_type = .cash,
            .account = "Brokerage",
        },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };

    const schwab_accounts = [_]AccountSummary{
        .{
            .account_name = "Brokerage",
            .account_number = "1234",
            .cash = 5500.0,
            .total_value = 5500.0,
        },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Brokerage",
            .tax_type = .taxable,
            .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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
    try std.testing.expect(results[0].has_discrepancy);
}

test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
    const allocator = std.testing.allocator;
    const today = Date.fromYmd(2026, 6, 19);

    // Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual.
    // Below the $1 securities tolerance, but a real cash drift that
    // must surface.
    const lots = [_]portfolio_mod.Lot{
        .{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };

    const schwab_accounts = [_]AccountSummary{
        .{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample Brokerage", .tax_type = .taxable, .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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
    try std.testing.expect(results[0].has_discrepancy);
}

test "compareSchwabSummary: account_number with no match -> empty account_name" {
    const allocator = std.testing.allocator;
    const today = Date.fromYmd(2026, 5, 8);

    const lots = [_]portfolio_mod.Lot{};
    const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };

    const schwab_accounts = [_]AccountSummary{
        .{
            .account_name = "Unknown Acct",
            .account_number = "9999",
            .cash = 1000.0,
            .total_value = 1000.0,
        },
    };

    var entries = [_]analysis.AccountTaxEntry{};
    const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };

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

    const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expectEqualStrings("", results[0].account_name);
    try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
    // No portfolio match -> cash and total are zero, schwab values become deltas
    try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
    try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
}

test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
    const allocator = std.testing.allocator;
    const today = Date.fromYmd(2026, 5, 8);

    const lots = [_]portfolio_mod.Lot{
        .{
            .symbol = "CASH",
            .shares = 5000,
            .open_date = Date.fromYmd(2024, 1, 1),
            .open_price = 1.0,
            .security_type = .cash,
            .account = "X",
        },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };

    // Schwab summary missing cash + total fields (.cash = null, .total_value = null).
    const schwab_accounts = [_]AccountSummary{
        .{
            .account_name = "X",
            .account_number = "1234",
            .cash = null,
            .total_value = null,
        },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "X",
            .tax_type = .taxable,
            .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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expect(results[0].cash_delta == null);
    try std.testing.expect(results[0].total_delta == null);
    // Null deltas are treated as "ok" (no discrepancy possible to assert).
    try std.testing.expect(!results[0].has_discrepancy);
}

test "compareSchwabSummary: today affects valuation of held assets" {
    const allocator = std.testing.allocator;

    // Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
    // open), it's not held -> portfolio_total excludes it. With
    // today=2025-01-01 (after open), portfolio_total includes 10 * price.
    const lots = [_]portfolio_mod.Lot{
        .{
            .symbol = "AAPL",
            .shares = 10,
            .open_date = Date.fromYmd(2024, 6, 1),
            .open_price = 150,
            .account = "Acct",
        },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };

    const schwab_accounts = [_]AccountSummary{
        .{
            .account_name = "Acct",
            .account_number = "1234",
            .cash = 0,
            .total_value = 2000,
        },
    };

    var entries = [_]analysis.AccountTaxEntry{
        .{
            .account = "Acct",
            .tax_type = .taxable,
            .institution = "schwab",
            .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("AAPL", 200.0);

    // Before open: portfolio holds nothing for this account.
    {
        const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
        defer allocator.free(results);
        try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
    }

    // After open: portfolio holds 10 * 200 = 2000.
    {
        const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
        defer allocator.free(results);
        try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
        // Matches schwab -> no discrepancy.
        try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
        try std.testing.expect(!results[0].has_discrepancy);
    }
}

// ── reconcile wrappers (parse + compare wiring) ──────────────

test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
    const allocator = std.testing.allocator;
    const csv =
        "\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
        "\n" ++
        "\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
        "\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";

    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
    };
    const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .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("AMZN", 239.208);

    const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
    defer {
        for (results) |r| allocator.free(r.comparisons);
        allocator.free(results);
    }

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
    var found_amzn = false;
    for (results[0].comparisons) |c| {
        if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
    }
    try std.testing.expect(found_amzn);
}

test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
    const allocator = std.testing.allocator;
    const data =
        \\Sample Roth
        \\Account number ending in 1234 ...1234
        \\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
        \\Sample Inherited IRA
        \\Account number ending in 5678 ...5678
        \\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
    ;
    const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
    var entries = [_]analysis.AccountTaxEntry{
        .{ .account = "Sample Roth 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 reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10));
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 2), results.len);
    // 1234 maps; 5678 is absent from the map -> unmapped (empty name).
    try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
    try std.testing.expectEqualStrings("", results[1].account_name);
}

test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 28);
    var lots = [_]portfolio_mod.Lot{
        // CD >1yr out -> 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 -> 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" },
    };
    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();
    var summary = [_]AccountSummary{
        .{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 },
    };

    const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of);
    defer allocator.free(results);

    try std.testing.expectEqual(@as(usize, 1), results.len);
    try std.testing.expect(results[0].has_options);
    try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01);
}

// ── summaryRatioSuggestions ──────────────────────────────────

test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + drift" {
    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 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 };
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    try prices.put("SPY", 500.0);
    // current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
    const results = [_]SchwabAccountComparison{
        .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
    };
    const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am);
    defer allocator.free(sugg);
    try std.testing.expectEqual(@as(usize, 1), sugg.len);
    try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol);
    try std.testing.expectApproxEqAbs(@as(f64, 1.0), sugg[0].current_ratio, 1e-9);
    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 "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all skip" {
    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 results = [_]SchwabAccountComparison{
        .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
    };

    // null account_map -> empty.
    {
        const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null);
        defer allocator.free(sugg);
        try std.testing.expectEqual(@as(usize, 0), sugg.len);
    }
    // account present but NOT direct-indexing -> skipped.
    {
        var entries = [_]analysis.AccountTaxEntry{
            .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
        };
        const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
        const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am);
        defer allocator.free(sugg);
        try std.testing.expectEqual(@as(usize, 0), sugg.len);
    }
    // direct-indexing but sub-cent delta -> skipped.
    {
        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 tiny = [_]SchwabAccountComparison{
            .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
        };
        const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am);
        defer allocator.free(sugg);
        try std.testing.expectEqual(@as(usize, 0), sugg.len);
    }
    // direct-indexing but two stock lots -> can't allocate the delta -> skipped.
    {
        var two = [_]portfolio_mod.Lot{
            .{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
            .{ .symbol = "QQQ", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
        };
        const pf2 = portfolio_mod.Portfolio{ .lots = &two, .allocator = allocator };
        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 summaryRatioSuggestions(allocator, &results, pf2, prices, am);
        defer allocator.free(sugg);
        try std.testing.expectEqual(@as(usize, 0), sugg.len);
    }
}
