const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const Money = @import("../Money.zig");
const analysis = @import("../analytics/analysis.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const contributions = @import("contributions.zig");
const brokerage_types = @import("../brokerage/types.zig");
const fidelity = @import("../brokerage/fidelity.zig");
const schwab = @import("../brokerage/schwab.zig");

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

// Local aliases used inside this file. The brokerage parsers and
// their record types live under `src/brokerage/`; this file is the
// reconciler that compares those records against the portfolio.
const BrokeragePosition = brokerage_types.BrokeragePosition;
const SchwabAccountSummary = schwab.AccountSummary;
const parseFidelityCsv = fidelity.parseCsv;
const parseSchwabCsv = schwab.parseCsv;
const parseSchwabSummary = schwab.parseSummary;
const fidelityOptionMatchesLot = fidelity.optionMatchesLot;

/// 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).
const value_tolerance: f64 = 1.0;
const cash_tolerance: f64 = 0.01;

/// 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,
    has_discrepancy: bool,
};

/// 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),
    };
}

/// Compare Schwab summary against portfolio.srf account totals.
pub fn compareSchwabSummary(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    schwab_accounts: []const SchwabAccountSummary,
    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;

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

        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) < cash_tolerance else true;
        const total_ok = if (total_delta) |d| @abs(d) < 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_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
        });
    }

    return results.toOwnedSlice(allocator);
}

fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
    try cli.printBold(out, color, "\nSchwab Account Audit", .{});
    try cli.printFg(out, color, cli.CLR_MUTED, "  (brokerage is source of truth)\n", .{});
    try out.print("========================================\n\n", .{});

    // Column headers
    try cli.printFg(out, color, cli.CLR_MUTED, "  {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
        "Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
    });

    var grand_pf: f64 = 0;
    var grand_br: f64 = 0;
    var discrepancy_count: usize = 0;

    for (results) |r| {
        const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;

        var br_cash_buf: [24]u8 = undefined;
        var br_total_buf: [24]u8 = undefined;

        const br_cash_str = if (r.schwab_cash) |c|
            std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
        else
            "--";
        const br_total_str = if (r.schwab_total) |t|
            std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
        else
            "--";

        const cash_ok = if (r.cash_delta) |d| @abs(d) < cash_tolerance else true;
        const total_ok = if (r.total_delta) |d| @abs(d) < value_tolerance else true;
        const is_unmapped = r.account_name.len == 0;
        const is_real_mismatch = !cash_ok or is_unmapped;

        if (is_real_mismatch) discrepancy_count += 1;

        // Account label
        try out.print("  ", .{});
        if (is_unmapped) {
            try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
        } else {
            try out.print("{s:<24}", .{label});
        }

        // PF Cash — colored if mismatched (brokerage is truth)
        try out.print(" ", .{});
        if (!cash_ok) {
            const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
            try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
        } else {
            try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
        }

        // BR Cash
        try out.print(" {s:>14}", .{br_cash_str});

        // PF Total — colored if not just stale prices
        try out.print(" ", .{});
        if (!total_ok and !cash_ok) {
            const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
            try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
        } else {
            try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
        }

        // BR Total
        try out.print(" {s:>14}", .{br_total_str});

        // Status
        if (is_unmapped) {
            try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
        } else if (!cash_ok) {
            const d = r.cash_delta.?;
            const sign: []const u8 = if (d >= 0) "+" else "-";
            try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
        } else if (!total_ok) {
            const d = r.total_delta.?;
            const sign: []const u8 = if (d >= 0) "+" else "-";
            try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
        }
        try out.print("\n", .{});

        grand_pf += r.portfolio_total;
        if (r.schwab_total) |t| grand_br += t;
    }

    // Grand totals
    try out.print("\n", .{});
    const grand_delta = grand_br - grand_pf;

    try cli.printBold(out, color, "  Total: portfolio {f}  schwab {f}", .{
        Money.from(grand_pf),
        Money.from(grand_br),
    });

    if (@abs(grand_delta) < 1.0) {
        // no delta
    } else {
        const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
        const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
        try cli.printFg(out, color, rgb, "  delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
    }
    try out.print("\n", .{});

    if (discrepancy_count > 0) {
        try cli.printFg(out, color, cli.CLR_WARNING, "  {d} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
            discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
        });
    }
    try out.print("\n", .{});
}

// ── 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,
    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;

        // 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;

            if (bp.is_cash) {
                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
                        // (Fidelity uses compact OCC format like "-AMZN260515C220"
                        //  while portfolio uses "AMZN 05/15/2026 220.00 C")
                        if (!std.mem.eql(u8, lot.symbol, bp.symbol) and
                            !fidelityOptionMatchesLot(bp.symbol, lot)) continue;
                        switch (lot.security_type) {
                            .cd => {
                                pf_shares += lot.shares;
                                pf_value += lot.shares;
                                pf_price = 1.0;
                            },
                            .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,
                .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,
                        .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);
}

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

/// After displaying audit results, check for price_ratio positions where
/// the brokerage NAV implies a different ratio than what's configured.
/// Outputs actionable suggestions for portfolio.srf updates.
///
/// Normally only lots with `price_ratio != 1.0` get suggestions —
/// the typical case is an institutional share class where the
/// configured ratio needs to drift toward current retail-vs-
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
/// nothing to adjust.
///
/// Exception: accounts flagged `direct_indexing::true` in
/// `accounts.srf`. These are proxy baskets whose tracking-error
/// drift is expressed by periodically nudging the ratio even
/// though the starting ratio is 1.0. For those accounts we still
/// emit a suggestion when brokerage and portfolio values disagree
/// — the suggested ratio is just `brokerage_NAV / retail_price`
/// applied against the existing lot share count, same formula as
/// the institutional-class case.
fn displayRatioSuggestions(
    results: []const AccountComparison,
    portfolio: zfin.Portfolio,
    prices: std.StringHashMap(f64),
    account_map: ?analysis.AccountMap,
    color: bool,
    out: *std.Io.Writer,
) !void {
    var has_header = false;

    for (results) |acct| {
        for (acct.comparisons) |cmp| {
            // Skip unmatched, cash, and option rows
            if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
            if (cmp.is_cash or cmp.is_option) continue;

            // Is this account flagged direct-indexing? Captured once
            // per outer loop so the per-lot gate can skip the
            // ratio == 1.0 check for flagged accounts.
            const is_direct_indexing = if (account_map) |am|
                am.isDirectIndexing(acct.account_name)
            else
                false;

            // Find the portfolio lot(s) for this symbol with price_ratio != 1.0
            // (or any ratio, for direct-indexing accounts).
            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;

                // Match by lot_symbol (CUSIP) or ticker against brokerage symbol
                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;

                // Get the retail price from cache
                const retail_price = prices.get(price_sym) orelse continue;
                // Brokerage price is the institutional NAV per share
                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;
                const drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0;

                // Only suggest if drift is meaningful (> 0.01%)
                if (current_ratio == suggested_ratio) break;

                if (!has_header) {
                    try out.print("\n", .{});
                    try cli.printBold(out, color, "  Ratio updates", .{});
                    try cli.printFg(out, color, cli.CLR_MUTED, "  (for portfolio.srf)\n", .{});
                    has_header = true;
                }

                var cur_buf: [24]u8 = undefined;
                var sug_buf: [24]u8 = undefined;
                var drift_buf: [16]u8 = undefined;
                const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{current_ratio}) catch "?";
                const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
                const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?";

                try out.print("    {s:<16} ", .{lot_sym});
                try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
                try out.print("  ratio {s} -> ", .{cur_str});
                try cli.printBold(out, color, "{s}", .{sug_str});
                try cli.printFg(out, color, cli.CLR_MUTED, "  ({s} drift)\n", .{drift_str});

                break; // One suggestion per symbol
            }
        }
    }

    if (has_header) try out.print("\n", .{});
}

/// Direct-indexing ratio suggestions from Schwab-summary data.
///
/// The Schwab summary path only gives us per-account totals, not
/// per-symbol detail. For a direct-indexing account with exactly one
/// stock lot (the common case — the account is the proxy basket,
/// tracked as a single benchmark lot), we can still emit a ratio
/// suggestion from the account-level `total_delta`:
///
///   current_stock_value = portfolio_total - portfolio_cash
///   target_stock_value  = current_stock_value + total_delta
///   suggested_ratio     = target_stock_value / (shares × price)
///
/// Where `price` is `shares × current_cached_price`. The math
/// assumes the full account delta lands on the single tracked lot,
/// which is the semantics of a direct-indexing proxy.
///
/// Skips accounts with more than one stock lot (can't allocate the
/// delta) or zero stock lots (nothing to adjust).
fn displaySchwabSummaryRatioSuggestions(
    results: []const SchwabAccountComparison,
    portfolio: zfin.Portfolio,
    prices: std.StringHashMap(f64),
    account_map: ?analysis.AccountMap,
    color: bool,
    out: *std.Io.Writer,
) !void {
    const am = account_map orelse return;
    var has_header = false;

    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);
        const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;

        if (!has_header) {
            try out.print("\n", .{});
            try cli.printBold(out, color, "  Ratio updates", .{});
            try cli.printFg(out, color, cli.CLR_MUTED, "  (for portfolio.srf; direct-indexing accounts)\n", .{});
            has_header = true;
        }

        var cur_buf: [24]u8 = undefined;
        var sug_buf: [24]u8 = undefined;
        var drift_buf: [16]u8 = undefined;
        const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
        const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
        const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";

        try out.print("    {s:<16} ", .{lot.symbol});
        try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
        try out.print("  ratio {s} -> ", .{cur_str});
        try cli.printBold(out, color, "{s}", .{sug_str});
        try cli.printFg(out, color, cli.CLR_MUTED, "  ({s} drift)\n", .{drift_str});
    }

    if (has_header) try out.print("\n", .{});
}

// ── Display ─────────────────────────────────────────────────

fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
    try cli.printBold(out, color, "\nPortfolio Audit", .{});
    try cli.printFg(out, color, cli.CLR_MUTED, "  (brokerage is source of truth)\n", .{});
    try out.print("========================================\n\n", .{});

    var total_portfolio: f64 = 0;
    var total_brokerage: f64 = 0;
    var total_option_delta: f64 = 0;
    var discrepancy_count: usize = 0;

    for (results) |acct| {
        // Account header
        if (acct.account_name.len > 0) {
            try cli.printBold(out, color, "  {s}", .{acct.account_name});
            try cli.printFg(out, color, cli.CLR_MUTED, "  ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
        } else {
            try cli.printBold(out, color, "  {s} #{s}", .{ acct.brokerage_name, acct.account_number });
            try cli.printFg(out, color, cli.CLR_WARNING, "  (unmapped — add account_number to accounts.srf)\n", .{});
        }

        // Column headers
        try cli.printFg(out, color, cli.CLR_MUTED, "    {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{
            "Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "",
        });

        for (acct.comparisons) |cmp| {
            var pf_shares_buf: [16]u8 = undefined;
            var br_shares_buf: [16]u8 = undefined;
            var pf_price_buf: [16]u8 = undefined;
            var br_price_buf: [16]u8 = undefined;

            // Classify this row
            const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
            const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
            const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;

            if (is_real_mismatch) discrepancy_count += 1;

            // Format share strings
            const pf_shares_str: []const u8 = if (cmp.is_cash)
                (std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?")
            else
                std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?";

            const br_shares_str: []const u8 = if (cmp.is_cash)
                (if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--")
            else if (cmp.brokerage_shares) |s|
                (std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?")
            else
                "--";

            const pf_price_str: []const u8 = if (cmp.is_cash)
                ""
            else if (cmp.portfolio_price) |p|
                (std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?")
            else
                "--";

            const br_price_str: []const u8 = if (cmp.is_cash)
                ""
            else if (cmp.brokerage_price) |p|
                (std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?")
            else
                "--";

            // Determine PF Shares color (relative to brokerage)
            const shares_color: enum { normal, positive, negative, warning } = blk: {
                if (cmp.only_in_portfolio) break :blk .warning;
                if (cmp.is_cash and is_cash_mismatch) {
                    break :blk if (cmp.value_delta.? > 0) .negative else .positive;
                }
                if (shares_ok) break :blk .normal;
                if (cmp.shares_delta) |d| {
                    break :blk if (d > 0) .negative else .positive;
                }
                break :blk .normal;
            };

            // Determine status text
            var status_buf: [64]u8 = undefined;
            const status: []const u8 = blk: {
                if (cmp.only_in_brokerage) break :blk "Brokerage only";
                if (cmp.only_in_portfolio) break :blk "Portfolio only";

                if (is_cash_mismatch) {
                    if (cmp.value_delta) |d| {
                        const sign: []const u8 = if (d >= 0) "+" else "-";
                        break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch";
                    }
                }

                if (!shares_ok) {
                    if (cmp.shares_delta) |d| {
                        const sign: []const u8 = if (d > 0) "+" else "";
                        break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch";
                    }
                }

                // Options: shares match is sufficient — value delta is expected
                // (cost basis vs mark-to-market) and not actionable
                if (cmp.is_option) break :blk "Option";

                // Shares match — show value delta (stale price) if any, muted
                if (cmp.value_delta) |d| {
                    if (@abs(d) >= value_tolerance) {
                        const sign: []const u8 = if (d >= 0) "+" else "-";
                        break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "";
                    }
                }

                break :blk "";
            };

            // Status color: real mismatches in warning, stale values muted, ok is blank
            const status_color: enum { warning, muted, none } = blk: {
                if (is_real_mismatch) break :blk .warning;
                if (status.len > 0) break :blk .muted;
                break :blk .none;
            };

            // Print symbol
            try out.print("    {s:<24} ", .{cmp.symbol});

            // Print PF Shares with color
            switch (shares_color) {
                .positive => try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{pf_shares_str}),
                .negative => try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s:>12}", .{pf_shares_str}),
                .warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s:>12}", .{pf_shares_str}),
                .normal => try out.print("{s:>12}", .{pf_shares_str}),
            }

            // Print BR Shares, prices
            try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str });

            // Print status
            switch (status_color) {
                .warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s}\n", .{status}),
                .muted => try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{status}),
                .none => try out.print("{s}\n", .{status}),
            }
        }

        // Account totals
        try cli.printFg(out, color, cli.CLR_MUTED, "    {s:<24} {s:>12} {s:>12} {f} {f} ", .{
            "", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10),
        });

        const adj_delta = acct.total_delta - acct.option_value_delta;
        if (@abs(adj_delta) < 1.0) {
            // no delta text needed
        } else {
            const sign: []const u8 = if (adj_delta >= 0) "+" else "-";
            const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
            try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) });
        }

        if (@abs(acct.option_value_delta) >= 1.0) {
            const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-";
            try cli.printFg(out, color, cli.CLR_MUTED, "  (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
        }
        try out.print("\n\n", .{});

        total_portfolio += acct.portfolio_total;
        total_brokerage += acct.brokerage_total;
        total_option_delta += acct.option_value_delta;
    }

    // Grand totals
    const grand_delta = total_brokerage - total_portfolio;
    const grand_adj_delta = grand_delta - total_option_delta;

    try cli.printBold(out, color, "  Total: portfolio {f}  brokerage {f}", .{
        Money.from(total_portfolio),
        Money.from(total_brokerage),
    });

    if (@abs(grand_adj_delta) < 1.0) {
        // no delta text needed
    } else {
        const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-";
        const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
        try cli.printFg(out, color, rgb, "  delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) });
    }

    if (@abs(total_option_delta) >= 1.0) {
        const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-";
        try cli.printFg(out, color, cli.CLR_MUTED, "  (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) });
    }
    try out.print("\n", .{});

    if (discrepancy_count > 0) {
        try cli.printFg(out, color, cli.CLR_WARNING, "  {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") });
    }
    try out.print("\n", .{});
}

// ── Hygiene check (flagless audit) ──────────────────────────

/// Constants for hygiene check behavior. Kept as named constants for
/// easy future tuning.
const audit_file_max_age_hours = 24;
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
const default_stale_days: u32 = 3;
const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold

/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
/// new_cash / new_cd / cash_contribution) gets flagged in the
/// "Large new lots — confirm source" hygiene section. Below this
/// threshold new lots pass silently — the audit's goal is to catch
/// unconfirmed six-figure movements, not flag every payroll
/// contribution.
///
/// $10k is a judgment call: high enough to ignore routine payroll
/// ESPP accruals and $1-$2k weekly deposits, low enough to surface
/// a typical IRA contribution or a genuine transfer. Tunable here,
/// per the plan's "revisit if the threshold proves wrong" note in
/// TODO.md.
const audit_large_lot_threshold: f64 = 10_000.0;

/// Type of a discovered brokerage file.
const BrokerFileKind = enum {
    fidelity_csv,
    schwab_csv,
    schwab_summary,
};

/// A discovered brokerage file ready for reconciliation.
const DiscoveredFile = struct {
    path: []const u8,
    kind: BrokerFileKind,
    dir_label: []const u8, // e.g. "audit/" or "$ZFIN_AUDIT_FILES"
};

/// Detect the brokerage type from file contents by inspecting the first few lines.
fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
    // Strip optional UTF-8 BOM
    const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF)
        data[3..]
    else
        data;

    // Fidelity CSV: first line starts with "Account Number" or "Account Name"
    if (std.mem.startsWith(u8, content, "Account Number") or
        std.mem.startsWith(u8, content, "Account Name"))
        return .fidelity_csv;

    // Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
    if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;

    // Schwab summary: contains "Account number ending in" pattern
    const peek = content[0..@min(content.len, 4096)];
    if (std.mem.indexOf(u8, peek, "Account number ending in") != null) return .schwab_summary;
    // Also match by account type labels + dollar amounts
    if ((std.mem.indexOf(u8, peek, "Brokerage") != null or
        std.mem.indexOf(u8, peek, "Roth IRA") != null or
        std.mem.indexOf(u8, peek, "Traditional IRA") != null or
        std.mem.indexOf(u8, peek, "Rollover IRA") != null) and
        std.mem.indexOf(u8, peek, "$") != null)
    {
        return .schwab_summary;
    }

    return null;
}

/// Discover brokerage files in a directory. Filters by recency (< 24h)
/// and applies size limits for non-CSV files.
fn discoverBrokerFiles(
    io: std.Io,
    allocator: std.mem.Allocator,
    dir_path: []const u8,
    dir_label: []const u8,
    now_s: i64,
) ![]DiscoveredFile {
    var results = std.ArrayList(DiscoveredFile).empty;
    defer results.deinit(allocator);

    var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator);
    defer dir.close(io);

    const max_age_s: i128 = audit_file_max_age_hours * 3600;

    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        if (entry.kind != .file) continue;

        // Check file modification time
        const stat = dir.statFile(io, entry.name, .{}) catch continue;
        const mtime_s: i128 = @divFloor(stat.mtime.nanoseconds, std.time.ns_per_s);
        const age_s = now_s - mtime_s;
        if (age_s > max_age_s) continue;

        // Check if it's a CSV (no size limit) or non-CSV (size limit applies)
        const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV");
        if (!is_csv and stat.size > audit_file_max_size_non_csv) continue;

        // Read and detect content type
        const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue;
        defer allocator.free(data);

        const kind = detectBrokerFileKind(data) orelse continue;
        const full_path = std.fs.path.join(allocator, &.{ dir_path, entry.name }) catch continue;
        try results.append(allocator, .{
            .path = full_path,
            .kind = kind,
            .dir_label = dir_label,
        });
    }

    return results.toOwnedSlice(allocator);
}

/// Compute which accounts have been modified between two parsed portfolios.
/// Returns a set of account names that have any lot-level differences.
/// Compares by serializing each lot to a canonical string per account,
/// sorting, and checking for equality. Simple and robust -- any field
/// change in a lot produces a different string.
fn findModifiedAccounts(
    allocator: std.mem.Allocator,
    old_portfolio: zfin.Portfolio,
    new_portfolio: zfin.Portfolio,
) !std.StringHashMap(void) {
    var modified = std.StringHashMap(void).init(allocator);
    errdefer modified.deinit();

    // Collect serialized lot strings grouped by account
    var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
    defer {
        var it = old_accts.valueIterator();
        while (it.next()) |v| {
            for (v.items) |s| allocator.free(s);
            v.deinit(allocator);
        }
        old_accts.deinit();
    }
    var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
    defer {
        var it = new_accts.valueIterator();
        while (it.next()) |v| {
            for (v.items) |s| allocator.free(s);
            v.deinit(allocator);
        }
        new_accts.deinit();
    }

    for (old_portfolio.lots) |lot| {
        const acct = lot.account orelse continue;
        const entry = try old_accts.getOrPut(acct);
        if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
        try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
    }
    for (new_portfolio.lots) |lot| {
        const acct = lot.account orelse continue;
        const entry = try new_accts.getOrPut(acct);
        if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
        try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
    }

    // Compare per account: sort both lists, then check equality
    var all = std.StringHashMap(void).init(allocator);
    defer all.deinit();
    {
        var it = old_accts.keyIterator();
        while (it.next()) |k| try all.put(k.*, {});
    }
    {
        var it = new_accts.keyIterator();
        while (it.next()) |k| try all.put(k.*, {});
    }

    var acct_it = all.keyIterator();
    while (acct_it.next()) |acct_key| {
        const acct = acct_key.*;
        const old_ptr = old_accts.getPtr(acct);
        const new_ptr = new_accts.getPtr(acct);
        const old_len = if (old_ptr) |p| p.items.len else 0;
        const new_len = if (new_ptr) |p| p.items.len else 0;

        if (old_len != new_len) {
            try modified.put(acct, {});
            continue;
        }
        if (old_len == 0) continue;

        const old_items = old_ptr.?.items;
        const new_items = new_ptr.?.items;
        std.mem.sort([]const u8, old_items, {}, strLessThan);
        std.mem.sort([]const u8, new_items, {}, strLessThan);

        var differs = false;
        for (old_items, new_items) |a, b| {
            if (!std.mem.eql(u8, a, b)) {
                differs = true;
                break;
            }
        }
        if (differs) try modified.put(acct, {});
    }

    return modified;
}

fn strLessThan(_: void, a: []const u8, b: []const u8) bool {
    return std.mem.order(u8, a, b) == .lt;
}

const srf = @import("srf");

/// Serialize a lot to a canonical SRF string for comparison.
/// Uses the SRF serializer with comptime reflection, so any new
/// field added to Lot is automatically included.
fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
    const lots = [_]portfolio_mod.Lot{lot};
    return std.fmt.allocPrint(allocator, "{f}", .{srf.fmt(portfolio_mod.Lot, &lots, .{ .emit_directives = false })});
}

/// Staleness color based on age vs threshold.
/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x.
fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
    const t: i32 = @intCast(threshold);
    if (age_days <= t) return cli.CLR_MUTED;
    if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING;
    return cli.CLR_NEGATIVE;
}

/// One stale (or undated) manual price found during the hygiene scan.
/// String fields borrow from the scanned portfolio's lots and are
/// valid for the lifetime of that portfolio.
const StaleManualPrice = struct {
    account: []const u8,
    symbol: []const u8,
    note: ?[]const u8,
    price: f64,
    /// `null` when the lot carries a manual `price` but no
    /// `price_date` — the most-stale case (it can't even be aged),
    /// not the least.
    price_date: ?Date,
    /// Days since `price_date`; `null` when undated.
    age_days: ?i32,
};

/// Collect manual-priced lots that are stale (older than `stale_days`)
/// or undated, for the "Stale manual prices" hygiene section.
///
/// Staleness is purely a property of the `price` / `price_date` the
/// user typed on the lot — it has nothing to do with the account's
/// `update_cadence` (that's the reconciliation cadence, a separate
/// concept handled by the "Accounts overdue" section). The single
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
///
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
/// carry `price` as a fixed face value that never goes "stale by age,"
/// and closed lots aren't worth nagging about. Undated manual prices
/// are always included regardless of `stale_days` — a manual price
/// with no `price_date` can't be aged, which is the worst case, not a
/// pass. Caller owns the returned list; string fields borrow from
/// `portfolio`.
fn collectStaleManualPrices(
    allocator: std.mem.Allocator,
    portfolio: zfin.Portfolio,
    as_of: Date,
    stale_days: u32,
) !std.ArrayList(StaleManualPrice) {
    var out = std.ArrayList(StaleManualPrice).empty;
    errdefer out.deinit(allocator);

    const threshold: i32 = @intCast(stale_days);
    for (portfolio.lots) |lot| {
        if (lot.security_type != .stock) continue;
        const price = lot.price orelse continue;
        if (!lot.isOpen(as_of)) continue;
        const account = lot.account orelse "(no account)";

        if (lot.price_date) |pd| {
            const age = as_of.days - pd.days;
            if (age <= threshold) continue; // fresh enough
            try out.append(allocator, .{
                .account = account,
                .symbol = lot.symbol,
                .note = lot.note,
                .price = price,
                .price_date = pd,
                .age_days = age,
            });
        } else {
            try out.append(allocator, .{
                .account = account,
                .symbol = lot.symbol,
                .note = lot.note,
                .price = price,
                .price_date = null,
                .age_days = null,
            });
        }
    }
    return out;
}

/// Sort stale manual prices by account, then symbol — so the display
/// can group lines under per-account headers.
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
    const acc = std.mem.order(u8, a.account, b.account);
    if (acc != .eq) return acc == .lt;
    return std.mem.order(u8, a.symbol, b.symbol) == .lt;
}

/// A lot whose manual `price` moved between HEAD and the working tree
/// while its `price_date` stayed identical — the "bumped the price,
/// forgot the date" mistake. String fields borrow from the working-
/// tree portfolio.
const PriceDateMismatch = struct {
    account: []const u8,
    symbol: []const u8,
    old_price: f64,
    new_price: f64,
    price_date: Date,
};

/// Build a lot identity that survives a manual-price edit: a lot keeps
/// its symbol, account, open_date, and open_price when you only change
/// `price`/`price_date`. Used to pair HEAD lots with working-tree lots.
/// Caller owns the returned slice.
fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
    return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{
        lot.symbol,
        lot.account orelse "",
        lot.open_date.days,
        lot.open_price,
    });
}

/// Find lots whose manual `price` changed between `committed` (HEAD)
/// and `working` (on-disk) while `price_date` stayed identical.
///
/// This is the working-tree-vs-HEAD detector for the recurring "I
/// updated the price but forgot the date" mistake, run when `audit`
/// fires before a commit. Only open `security_type == .stock` lots
/// with a non-null `price_date` on both sides participate: the undated
/// case is reported by `collectStaleManualPrices`, and legitimate
/// back-dating (moving the date to a past close) is *not* flagged
/// because the date field changed. Lots are paired by
/// `lotIdentityKey`; newly-added or removed lots are ignored. Caller
/// owns the returned list; string fields borrow from `working`.
fn findPriceDateMismatches(
    allocator: std.mem.Allocator,
    committed: zfin.Portfolio,
    working: zfin.Portfolio,
    as_of: Date,
) !std.ArrayList(PriceDateMismatch) {
    var out = std.ArrayList(PriceDateMismatch).empty;
    errdefer out.deinit(allocator);

    // Index HEAD lots by stable identity → their (price, price_date).
    const HeadLot = struct { price: ?f64, price_date: ?Date };
    var head = std.StringHashMap(HeadLot).init(allocator);
    defer {
        var it = head.keyIterator();
        while (it.next()) |k| allocator.free(k.*);
        head.deinit();
    }
    for (committed.lots) |lot| {
        if (lot.security_type != .stock) continue;
        const key = try lotIdentityKey(allocator, lot);
        const gop = try head.getOrPut(key);
        if (gop.found_existing) {
            // Duplicate identity (rare) — ambiguous, don't guess.
            allocator.free(key);
            continue;
        }
        gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date };
    }

    for (working.lots) |lot| {
        if (lot.security_type != .stock) continue;
        if (!lot.isOpen(as_of)) continue;
        const new_price = lot.price orelse continue;
        const new_date = lot.price_date orelse continue; // undated → stale-price section's job
        const key = try lotIdentityKey(allocator, lot);
        defer allocator.free(key);
        const prior = head.get(key) orelse continue; // newly-added lot
        const old_price = prior.price orelse continue; // price added, not moved
        const old_date = prior.price_date orelse continue; // was undated before
        // The mistake: price moved, date did not.
        if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) {
            try out.append(allocator, .{
                .account = lot.account orelse "(no account)",
                .symbol = lot.symbol,
                .old_price = old_price,
                .new_price = new_price,
                .price_date = new_date,
            });
        }
    }
    return out;
}

/// Sort price/date mismatches by account, then symbol — for grouped
/// per-account display.
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
    const acc = std.mem.order(u8, a.account, b.account);
    if (acc != .eq) return acc == .lt;
    return std.mem.order(u8, a.symbol, b.symbol) == .lt;
}

/// Render one unmatched large-lot warning. Formats the line the
/// user needs to paste into `transaction_log.srf` if the lot was
/// an internal movement rather than a real external contribution.
/// Leaves `from::<SOURCE>` as a placeholder — the audit doesn't
/// know which account the money came from.
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
/// (or cash_contribution) destinations use `dest_lot::cash`. The
/// template always uses `type::cash` since `type::in_kind` is
/// rejected downstream in v1.
fn printLargeLotWarning(
    out: *std.Io.Writer,
    lot: contributions.UnmatchedLargeLot,
    color: bool,
) !void {
    var val_buf: [32]u8 = undefined;
    var date_buf: [10]u8 = undefined;
    const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
    const kind_label: []const u8 = switch (lot.security_type) {
        .stock => "STOCK",
        .cash => "CASH",
        .cd => "CD",
        .option => "OPTION",
        else => "LOT",
    };

    const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash";
    try out.print(
        "    {s}: new {s} lot {s} ",
        .{ lot.account, kind_label, sym_for_display },
    );
    try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
    try out.print(" on {s}\n", .{date_str});

    try cli.printFg(out, color, cli.CLR_MUTED, "      If this was an external contribution: no action needed.\n", .{});
    try cli.printFg(out, color, cli.CLR_MUTED, "      If this was an internal transfer, add to transaction_log.srf:\n", .{});

    // Amount formatted with cents precision so the suggested
    // `amount:num:N` exactly matches the lot's value. The matcher
    // has a $1 tolerance so a whole-dollar suggestion would usually
    // pair, but pasting a value that lies about the actual lot is
    // a poor user experience — `transaction_log.srf` should record
    // what actually moved.
    if (lot.security_type == .cash) {
        try cli.printFg(
            out,
            color,
            cli.CLR_MUTED,
            "        transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
            .{ date_str, lot.value, lot.account },
        );
    } else {
        try cli.printFg(
            out,
            color,
            cli.CLR_MUTED,
            "        transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
            .{ date_str, lot.value, lot.account, lot.symbol, date_str },
        );
    }
}

/// Run the flagless portfolio hygiene check.
fn runHygieneCheck(
    io: std.Io,
    allocator: std.mem.Allocator,
    env: *const std.process.Environ.Map,
    svc: *zfin.DataService,
    portfolio_path: []const u8,
    stale_days: u32,
    verbose: bool,
    as_of: Date,
    now_s: i64,
    color: bool,
    refresh: framework.RefreshPolicy,
    out: *std.Io.Writer,
) !void {
    // Load portfolio
    const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
        cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
        return;
    };
    defer allocator.free(pf_data);

    var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
        cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
        return;
    };
    defer portfolio.deinit();

    // Load accounts.srf
    var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse {
        cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
        return;
    };
    defer account_map.deinit();

    try cli.printBold(out, color, "  Portfolio hygiene\n", .{});

    // ── Section 1: Stale manual prices ──
    //
    // Manual prices on stock/fund lots whose `price_date` is older than
    // `--stale-days` (default 3), plus manual prices with no
    // `price_date` at all (the most-stale case). Staleness is a
    // property of the price the user typed on the lot — not of the
    // account; grouping by account here is display-only organization.
    // CDs/cash/options are excluded — their `price` is a fixed face
    // value, not an age-stale quote — as are closed lots.
    {
        var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
        defer stale.deinit(allocator);
        std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);

        try out.print("\n", .{});
        try cli.printFg(out, color, cli.CLR_MUTED, "  Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});

        if (stale.items.len == 0) {
            try cli.printFg(out, color, cli.CLR_POSITIVE, "    (none)\n", .{});
        } else {
            var current_account: ?[]const u8 = null;
            for (stale.items) |e| {
                if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) {
                    current_account = e.account;
                    try cli.printFg(out, color, cli.CLR_HEADER, "    {s}\n", .{e.account});
                }

                var price_buf: [24]u8 = undefined;
                const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?";
                const note_display = e.note orelse "";

                if (e.price_date) |pd| {
                    var date_buf: [10]u8 = undefined;
                    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
                    try out.print("      {s:<14} {s:<16} {s:>12}   {s}  ", .{ e.symbol, note_display, price_str, date_str });
                    const clr = stalenessColor(e.age_days.?, stale_days);
                    try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))});
                } else {
                    try out.print("      {s:<14} {s:<16} {s:>12}   ", .{ e.symbol, note_display, price_str });
                    try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{});
                }
            }
        }
    }

    // ── Section 2: Account cadence check ──
    {
        // Try to get committed version via git
        const git = @import("../git.zig");
        const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, env, portfolio_path) catch null;
        defer if (repo_info) |ri| {
            allocator.free(ri.root);
            allocator.free(ri.rel_path);
        };

        // Parse committed portfolio for diff (working copy vs HEAD)
        var committed_portfolio: ?zfin.Portfolio = null;
        defer if (committed_portfolio) |*cp| cp.deinit();

        var committed_data: ?[]const u8 = null;
        defer if (committed_data) |d| allocator.free(d);

        if (repo_info) |ri| {
            committed_data = git.show(io, allocator, env, ri.root, "HEAD", ri.rel_path) catch null;
            if (committed_data) |cd| {
                committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null;
            }
        }

        // ── Section 1b: manual price changed without bumping price_date ──
        //
        // Catches the recurring "I updated the price but forgot the
        // date" mistake at the moment it matters — when `audit` runs
        // against the working tree before a commit. Diffs the on-disk
        // portfolio against HEAD; a lot whose `price` moved while its
        // `price_date` stayed put is flagged. Silent when there's no
        // committed version to compare against (not a repo / new file)
        // and when no such mismatch exists.
        if (committed_portfolio) |cp| {
            var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of);
            defer mismatches.deinit(allocator);
            std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan);

            if (mismatches.items.len > 0) {
                try out.print("\n", .{});
                try cli.printFg(out, color, cli.CLR_MUTED, "  Manual price changed without updating price_date (working tree vs HEAD)\n", .{});
                var current_account: ?[]const u8 = null;
                for (mismatches.items) |m| {
                    if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) {
                        current_account = m.account;
                        try cli.printFg(out, color, cli.CLR_HEADER, "    {s}\n", .{m.account});
                    }
                    var old_buf: [24]u8 = undefined;
                    var new_buf: [24]u8 = undefined;
                    var date_buf: [10]u8 = undefined;
                    const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
                    const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
                    const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
                    try out.print("      {s:<14} {s} → {s}   ", .{ m.symbol, old_str, new_str });
                    try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} — bump it\n", .{date_str});
                }
            }
        }

        // Find accounts modified in working copy (uncommitted changes)
        var working_copy_modified = std.StringHashMap(void).init(allocator);
        defer working_copy_modified.deinit();

        if (committed_portfolio) |cp| {
            working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator);
        }

        // Collect all unique account names from working copy portfolio
        // (these pointers are stable for the lifetime of the function)
        var all_accounts = std.StringHashMap(void).init(allocator);
        defer all_accounts.deinit();
        for (portfolio.lots) |lot| {
            if (lot.account) |acct| {
                try all_accounts.put(acct, {});
            }
        }

        // Find last update time for each account via git history.
        // Walk commits newest-to-oldest, diffing adjacent pairs to find
        // which accounts changed. Use working-copy account names as keys
        // (stable lifetime) rather than historical portfolio strings.
        // Only walk back far enough to hit red status (2× max cadence).
        var last_update_ts = std.StringHashMap(i64).init(allocator);
        defer last_update_ts.deinit();

        if (repo_info) |ri| {
            // Compute the furthest we need to look back: 2× the max cadence
            var max_threshold: u32 = 14; // 2× weekly default
            for (account_map.entries) |entry| {
                if (entry.update_cadence.thresholdDays()) |td| {
                    const red = td * stale_warning_multiplier;
                    if (red > max_threshold) max_threshold = red;
                }
            }
            var since_buf: [32]u8 = undefined;
            const since = std.fmt.bufPrint(&since_buf, "{d} days ago", .{max_threshold}) catch "30 days ago";

            const commits = git.listCommitsTouching(io, allocator, env, ri.root, ri.rel_path, since) catch &.{};
            defer git.freeCommitTouches(allocator, commits);

            var prev_data: ?[]const u8 = null;
            defer if (prev_data) |pd| allocator.free(pd);

            for (commits, 0..) |ct, ci| {
                // Stop early if every account already has a timestamp
                if (last_update_ts.count() >= all_accounts.count()) break;

                const rev_data = git.show(io, allocator, env, ri.root, ct.commit, ri.rel_path) catch continue;

                if (ci > 0) {
                    if (prev_data) |pd| {
                        // rev_data is older, pd is newer (commits are newest-first)
                        var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch {
                            allocator.free(rev_data);
                            continue;
                        };
                        defer old_pf.deinit();
                        var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch continue;
                        defer new_pf.deinit();

                        var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch continue;
                        defer mods.deinit();

                        // The newer commit's timestamp is when these accounts were updated
                        const update_ts = commits[ci - 1].timestamp;

                        // Match against stable working-copy account names
                        var acct_iter = all_accounts.keyIterator();
                        while (acct_iter.next()) |stable_name| {
                            if (last_update_ts.contains(stable_name.*)) continue;
                            if (mods.contains(stable_name.*)) {
                                try last_update_ts.put(stable_name.*, update_ts);
                            }
                        }
                    }
                }

                if (prev_data) |pd| allocator.free(pd);
                prev_data = rev_data;
            }
        }

        // Display overdue accounts
        var overdue_header_shown = false;
        var updated_accounts = std.ArrayList([]const u8).empty;
        defer updated_accounts.deinit(allocator);

        // Check accounts updated in working copy
        var wc_it = working_copy_modified.keyIterator();
        while (wc_it.next()) |key| {
            try updated_accounts.append(allocator, key.*);
        }

        // Check overdue accounts
        var acct_it = all_accounts.keyIterator();
        while (acct_it.next()) |acct_key| {
            const acct_name = acct_key.*;

            // Skip if already updated in working copy
            if (working_copy_modified.contains(acct_name)) continue;

            // Look up cadence from accounts.srf
            var cadence = analysis.UpdateCadence.weekly; // default
            for (account_map.entries) |entry| {
                if (std.mem.eql(u8, entry.account, acct_name)) {
                    cadence = entry.update_cadence;
                    break;
                }
            }

            const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none'

            // Find last update time
            var age_days: ?i32 = null;
            if (last_update_ts.get(acct_name)) |ts| {
                const age_s = now_s - ts;
                age_days = @intCast(@divFloor(age_s, std.time.s_per_day));
            }

            // If we have no git history for this account, it's definitely overdue
            const days = age_days orelse @as(i32, @intCast(threshold_days + 1));
            if (days <= @as(i32, @intCast(threshold_days))) continue;

            if (!overdue_header_shown) {
                try out.print("\n", .{});
                try cli.printFg(out, color, cli.CLR_MUTED, "  Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{});
                overdue_header_shown = true;
            }

            try out.print("    {s:<32} {s:<10}", .{ acct_name, cadence.label() });
            if (age_days) |ad| {
                const clr = stalenessColor(ad, threshold_days);
                try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
            } else {
                try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{});
            }
        }

        // Display accounts updated in working copy
        if (updated_accounts.items.len > 0) {
            try out.print("\n", .{});
            try cli.printFg(out, color, cli.CLR_MUTED, "  Accounts updated (working copy)\n", .{});
            for (updated_accounts.items) |acct| {
                try cli.printFg(out, color, cli.CLR_POSITIVE, "    {s}\n", .{acct});
            }
        }
    }

    // ── Section 3: Discover brokerage files ──

    // Resolve audit directories
    const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";

    var all_files = std.ArrayList(DiscoveredFile).empty;
    defer {
        for (all_files.items) |f| allocator.free(f.path);
        all_files.deinit(allocator);
    }

    // Check $ZFIN_AUDIT_FILES first
    const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null;
    if (env_audit_dir) |edir| {
        const env_files = try discoverBrokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s);
        defer allocator.free(env_files);
        for (env_files) |f| try all_files.append(allocator, f);
    }

    // Then check {portfolio_dir}/audit/
    const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null;
    defer if (default_audit_dir) |d| allocator.free(d);

    if (default_audit_dir) |adir| {
        const dir_files = try discoverBrokerFiles(io, allocator, adir, "audit/", now_s);
        defer allocator.free(dir_files);
        for (dir_files) |f| try all_files.append(allocator, f);
    }

    // Display discovered files
    if (all_files.items.len > 0) {
        try out.print("\n", .{});
        try cli.printFg(out, color, cli.CLR_MUTED, "  Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
        for (all_files.items) |f| {
            const kind_label: []const u8 = switch (f.kind) {
                .fidelity_csv => "fidelity",
                .schwab_csv => "schwab csv",
                .schwab_summary => "schwab summary",
            };
            try out.print("    {s:<52} {s}\n", .{ f.path, kind_label });
        }
    }

    // ── Section 4: Auto-reconcile discovered files ──

    if (all_files.items.len > 0) {
        // Build prices map (shared by all reconciliations)
        var prices = std.StringHashMap(f64).init(allocator);
        defer prices.deinit();
        {
            const pos_syms = try portfolio.stockSymbols(allocator);
            defer allocator.free(pos_syms);
            if (pos_syms.len > 0) {
                var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color);
                defer load_result.deinit();
                var pit = load_result.prices.iterator();
                while (pit.next()) |entry| {
                    try prices.put(entry.key_ptr.*, entry.value_ptr.*);
                }
            }
            for (portfolio.lots) |lot| {
                if (lot.price) |p| {
                    if (!prices.contains(lot.priceSymbol())) {
                        try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
                    }
                }
            }
        }

        try out.print("\n", .{});
        try cli.printBold(out, color, "  Reconciliation\n", .{});

        for (all_files.items) |f| {
            const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
            defer allocator.free(file_data);

            switch (f.kind) {
                .schwab_summary => {
                    const schwab_accounts = parseSchwabSummary(allocator, file_data) catch continue;
                    defer allocator.free(schwab_accounts);

                    const results = compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of) catch continue;
                    defer allocator.free(results);

                    if (verbose or hasSchwabDiscrepancies(results)) {
                        try out.print("\n", .{});
                        try displaySchwabResults(results, color, out);
                        try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
                    } else {
                        var acct_count: usize = 0;
                        for (results) |r| {
                            if (r.account_name.len > 0) acct_count += 1;
                        }
                        try cli.printFg(out, color, cli.CLR_POSITIVE, "    schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
                        // Always show ratio suggestions even in compact
                        // mode — direct-indexing drift may cause a
                        // non-zero delta that still deserves a nudge.
                        try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
                    }
                },
                .fidelity_csv => {
                    const brokerage_positions = parseFidelityCsv(allocator, file_data) catch continue;
                    defer allocator.free(brokerage_positions);

                    const results = compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of) catch continue;
                    defer {
                        for (results) |r| allocator.free(r.comparisons);
                        allocator.free(results);
                    }

                    if (verbose or hasAccountDiscrepancies(results)) {
                        try out.print("\n", .{});
                        try displayResults(results, color, out);
                        try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
                    } else {
                        try cli.printFg(out, color, cli.CLR_POSITIVE, "    fidelity: {d} accounts, no discrepancies\n", .{results.len});
                        // Always show ratio suggestions even in compact mode
                        try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
                    }
                },
                .schwab_csv => {
                    const parsed = parseSchwabCsv(allocator, file_data) catch continue;
                    defer allocator.free(parsed.positions);

                    const results = compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of) catch continue;
                    defer {
                        for (results) |r| allocator.free(r.comparisons);
                        allocator.free(results);
                    }

                    if (verbose or hasAccountDiscrepancies(results)) {
                        try out.print("\n", .{});
                        try displayResults(results, color, out);
                        try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
                    } else {
                        try cli.printFg(out, color, cli.CLR_POSITIVE, "    schwab: {d} accounts, no discrepancies\n", .{results.len});
                        try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
                    }
                },
            }
        }
    }

    // ── Section 5: Large new lots — confirm source ──
    //
    // Cross-check any new_* Change with value >= threshold against
    // `transaction_log.srf` (via the shared contributions pipeline).
    // Surfaces lots that look like significant external contributions
    // OR unrecorded internal transfers — nudges the user to either
    // confirm or add a transfer record.
    //
    // Silent when every large lot matched a transfer record, when
    // there are no new lots at all, or when the pipeline can't run
    // (not in a git repo). Threshold is a judgment call; see
    // `audit_large_lot_threshold`.
    if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| {
        var found_mut = found;
        defer found_mut.deinit();

        if (found_mut.lots.len > 0) {
            try out.print("\n", .{});
            try cli.printFg(out, color, cli.CLR_MUTED, "  Large new lots — confirm source\n", .{});
            for (found_mut.lots) |lot| {
                try printLargeLotWarning(out, lot, color);
            }
        }
    }

    try out.print("\n", .{});
}

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

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

// ── CLI entry point ─────────────────────────────────────────

pub const ParsedArgs = struct {
    fidelity_csv: ?[]const u8 = null,
    schwab_csv: ?[]const u8 = null,
    schwab_summary: bool = false,
    verbose: bool = false,
    stale_days: u32 = default_stale_days,
};

pub const meta: framework.Meta = .{
    .name = "audit",
    .group = .hygiene,
    .synopsis = "Reconcile portfolio against brokerage exports + portfolio hygiene check",
    .help =
    \\Usage: zfin audit [opts]
    \\
    \\Two modes in one command:
    \\
    \\  Flagless: run the portfolio hygiene check — surfaces stale
    \\  manual prices, account-cadence violations, and brokerage-file
    \\  candidates discovered automatically.
    \\
    \\  With brokerage flags: reconcile the portfolio against the
    \\  given export and report discrepancies.
    \\
    \\Options:
    \\  --verbose             Show full reconciliation output even when clean
    \\  --stale-days <N>      Manual price staleness threshold (default 3)
    \\  --fidelity <CSV>      Fidelity positions CSV export
    \\                        ("All accounts" → Positions tab → Download)
    \\  --schwab <CSV>        Schwab per-account positions CSV export
    \\  --schwab-summary      Schwab account summary; copy from accounts
    \\                        summary page, paste to stdin, then ^D
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var parsed: ParsedArgs = .{};
    var i: usize = 0;
    while (i < cmd_args.len) : (i += 1) {
        const a = cmd_args[i];
        if (std.mem.eql(u8, a, "--fidelity")) {
            parsed.fidelity_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
        } else if (std.mem.eql(u8, a, "--schwab")) {
            parsed.schwab_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
        } else if (std.mem.eql(u8, a, "--schwab-summary")) {
            parsed.schwab_summary = true;
        } else if (std.mem.eql(u8, a, "--verbose")) {
            parsed.verbose = true;
        } else if (std.mem.eql(u8, a, "--stale-days")) {
            const v = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
            // Reject a non-integer value loudly instead of silently
            // falling back to the default; a typo'd threshold that
            // silently reverts to 3 is a foot-gun.
            parsed.stale_days = std.fmt.parseInt(u32, v, 10) catch {
                cli.stderrPrint(ctx.io, "Error: --stale-days requires a non-negative integer, got: ");
                cli.stderrPrint(ctx.io, v);
                cli.stderrPrint(ctx.io, "\n");
                return error.UnexpectedArg;
            };
        } else {
            // Unknown flag or stray positional: reject explicitly
            // rather than silently ignoring it (which previously let
            // `audit --bogus` run flagless hygiene mode with no error).
            cli.stderrPrint(ctx.io, "Error: unknown argument to 'audit': ");
            cli.stderrPrint(ctx.io, a);
            cli.stderrPrint(ctx.io, "\nRun 'zfin audit --help' for usage.\n");
            return error.UnexpectedArg;
        }
    }
    return parsed;
}

pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
    const svc = ctx.svc orelse return error.MissingDataService;
    const io = ctx.io;
    const allocator = ctx.allocator;
    const out = ctx.out;
    const color = ctx.color;
    const as_of = ctx.today;
    const now_s = ctx.now_s;

    const fidelity_csv = parsed.fidelity_csv;
    const schwab_csv = parsed.schwab_csv;
    const schwab_summary = parsed.schwab_summary;
    const verbose = parsed.verbose;
    const stale_days = parsed.stale_days;

    // Flagless mode: run portfolio hygiene check (single-file
    // semantics — git blame, commit SHAs, etc.). Resolve paths
    // just to find the anchor; we don't need the merged view.
    if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
        const pf = ctx.resolvePortfolioPath();
        defer pf.deinit(allocator);
        return runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out);
    }

    // Reconciliation modes (--fidelity / --schwab / --schwab-summary):
    // load the union of all portfolio files so the comparison sees
    // every lot the user holds, even if they're split across multiple
    // portfolio_*.srf files.
    var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
    defer loaded.deinit(allocator);
    const portfolio = loaded.portfolio;
    const portfolio_path = loaded.anchor();

    // Load accounts.srf
    var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse {
        cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
        return;
    };
    defer account_map.deinit();

    // Build prices map, shared by all audit modes.
    //
    // Route through `cli.loadPortfolioPrices` so the audit gets the same
    // TTL-based cache refresh behavior `zfin portfolio` uses. Previously
    // this read cached last-closes directly, which silently used stale
    // data after long weekends / when the cache hadn't been refreshed.
    // TTL-driven refetch keeps numbers current without forcing a full
    // provider hit every run.
    var prices = std.StringHashMap(f64).init(allocator);
    defer prices.deinit();
    {
        const pos_syms = try portfolio.stockSymbols(allocator);
        defer allocator.free(pos_syms);

        if (pos_syms.len > 0) {
            var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, ctx.globals.refresh_policy, color);
            defer load_result.deinit();
            var it = load_result.prices.iterator();
            while (it.next()) |entry| {
                try prices.put(entry.key_ptr.*, entry.value_ptr.*);
            }
        }

        // Manual `price::` overrides from portfolio.srf still win for lots
        // that carry them (e.g. 401k CIT shares with no API coverage).
        for (portfolio.lots) |lot| {
            if (lot.price) |p| {
                if (!prices.contains(lot.priceSymbol())) {
                    // Pre-multiply — see "Pricing model" in models/portfolio.zig.
                    try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
                }
            }
        }
    }

    // Schwab summary from stdin
    if (schwab_summary) {
        cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
        var stdin_reader_buf: [4096]u8 = undefined;
        var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
        const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
            cli.stderrPrint(io, "Error: Cannot read stdin\n");
            return;
        };
        defer allocator.free(stdin_data);

        const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch {
            cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
            return;
        };
        defer allocator.free(schwab_accounts);

        const results = try compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
        defer allocator.free(results);

        try displaySchwabResults(results, color, out);
        try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
    }

    // Fidelity CSV
    if (fidelity_csv) |csv_path| {
        const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
            var msg_buf: [256]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
            cli.stderrPrint(io, msg);
            return;
        };
        defer allocator.free(csv_data);

        const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch {
            cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
            return;
        };
        defer allocator.free(brokerage_positions);

        const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of);
        defer {
            for (results) |r| allocator.free(r.comparisons);
            allocator.free(results);
        }

        try displayResults(results, color, out);
        try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
    }

    // Schwab per-account CSV
    if (schwab_csv) |csv_path| {
        const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
            var msg_buf: [256]u8 = undefined;
            const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
            cli.stderrPrint(io, msg);
            return;
        };
        defer allocator.free(csv_data);

        const csv = parseSchwabCsv(allocator, csv_data) catch {
            cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
            return;
        };
        defer allocator.free(csv.positions);

        const results = try compareAccounts(allocator, portfolio, csv.positions, account_map, "schwab", prices, as_of);
        defer {
            for (results) |r| allocator.free(r.comparisons);
            allocator.free(results);
        }

        try displayResults(results, color, out);
        try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
    }
}

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

test "parseArgs: defaults" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.fidelity_csv == null);
    try std.testing.expect(parsed.schwab_csv == null);
    try std.testing.expect(!parsed.schwab_summary);
    try std.testing.expect(!parsed.verbose);
    try std.testing.expectEqual(default_stale_days, parsed.stale_days);
}

test "parseArgs: --fidelity captures CSV path" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
}

test "parseArgs: --schwab captures CSV path" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--schwab", "/tmp/sch.csv" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?);
}

test "parseArgs: --schwab-summary boolean" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--schwab-summary"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.schwab_summary);
}

test "parseArgs: --verbose boolean" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--verbose"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expect(parsed.verbose);
}

test "parseArgs: --stale-days parses integer" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--stale-days", "5" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
}

test "parseArgs: unknown flag is rejected (not silently ignored)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--bogus"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: stray positional is rejected" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"AAPL"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: --fidelity without a value is rejected (no silent mode switch)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--fidelity"};
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --fidelity followed by a flag does not swallow the flag" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--fidelity", "--verbose" };
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --schwab without a value is rejected" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--schwab"};
    try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}

test "parseArgs: --stale-days with a non-integer value is rejected (not a silent default)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--stale-days", "abc" };
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

test "parseArgs: combined valid flags parse together" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv", "--verbose", "--stale-days", "7" };
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
    try std.testing.expect(parsed.verbose);
    try std.testing.expectEqual(@as(u32, 7), parsed.stale_days);
}

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 "detectBrokerFileKind: fidelity csv" {
    const fidelity_header = "Account Number,Account Name,Symbol,Description";
    try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
}

test "detectBrokerFileKind: fidelity csv with BOM" {
    const fidelity_bom = "\xEF\xBB\xBFAccount Number,Account Name,Symbol";
    try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?);
}

test "detectBrokerFileKind: schwab csv" {
    const schwab_header = "\"Positions for account Roth IRA ...1234 as of\"";
    try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?);
}

test "detectBrokerFileKind: schwab summary" {
    const schwab_summary_data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00";
    try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(schwab_summary_data).?);
}

test "detectBrokerFileKind: unknown file" {
    const random_data = "This is just some random text that doesn't match any pattern";
    try std.testing.expect(detectBrokerFileKind(random_data) == null);
}

test "stalenessColor: within threshold" {
    try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3));
    try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3));
}

test "stalenessColor: warning zone (1-2x threshold)" {
    try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3));
    try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3));
}

test "stalenessColor: critical zone (>2x threshold)" {
    try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3));
    try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3));
}

test "UpdateCadence thresholdDays" {
    try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays());
    try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays());
    try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays());
    try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null);
}

test "findModifiedAccounts: detects share changes" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed
        .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("Acct A"));
    try std.testing.expect(!modified.contains("Acct B"));
}

test "findModifiedAccounts: detects new lots" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        .{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" },
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("Acct A"));
}

test "findModifiedAccounts: detects price changes" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) },
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("401k"));
}

test "findModifiedAccounts: detects removed lots" {
    const allocator = std.testing.allocator;

    var old_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        .{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" },
    };
    var new_lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
        // VTI removed
    };

    const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
    const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
    defer modified.deinit();

    try std.testing.expect(modified.contains("Acct A"));
}

test "findModifiedAccounts: no changes" {
    const allocator = std.testing.allocator;

    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
    };

    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var modified = try findModifiedAccounts(allocator, pf, pf);
    defer modified.deinit();

    try std.testing.expectEqual(@as(u32, 0), modified.count());
}

// ── collectStaleManualPrices ─────────────────────────────────

test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };

    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);

    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqualStrings("Sample 529", stale.items[0].account);
    try std.testing.expectEqualStrings("F529A", stale.items[0].symbol);
    try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days);
}

test "collectStaleManualPrices: price within threshold is skipped" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleManualPrices: undated manual price is always flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), stale.items.len);
    try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date);
    try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
}

test "collectStaleManualPrices: CDs and cash are excluded" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) },
        .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleManualPrices: closed lot is excluded" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

test "collectStaleManualPrices: lot without a manual price is skipped" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
    defer stale.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}

// ── findPriceDateMismatches ──────────────────────────────────

test "findPriceDateMismatches: price moved but date unchanged is flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };

    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 1), m.items.len);
    try std.testing.expectEqualStrings("F529A", m.items[0].symbol);
    try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price);
    try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price);
}

test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: unchanged price is not flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var lots = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, pf, pf, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: newly-added lot is not flagged" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{};
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: undated working lot is left to the stale-price section" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

test "findPriceDateMismatches: CD price change is ignored" {
    const allocator = std.testing.allocator;
    const as_of = Date.fromYmd(2026, 6, 1);
    var head = [_]portfolio_mod.Lot{
        .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    var work = [_]portfolio_mod.Lot{
        .{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) },
    };
    const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
    const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
    var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
    defer m.deinit(allocator);
    try std.testing.expectEqual(@as(usize, 0), m.items.len);
}

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));
}

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 "detectBrokerFileKind: schwab csv with Positions header" {
    const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
    try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
}

test "detectBrokerFileKind: schwab summary with Roth IRA" {
    const data = "Roth IRA ...1234\nSome text\n$50,000.00\n";
    try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?);
}

test "UpdateCadence label" {
    try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label());
    try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label());
    try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label());
    try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label());
}

test "discoverBrokerFiles: finds files in temp directory" {
    const io = std.testing.io;
    const allocator = std.testing.allocator;

    // Create a temp directory with test files
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // Write a fidelity CSV
    tmp.dir.writeFile(io, .{
        .sub_path = "fidelity.csv",
        .data = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value\nZ123,Test,AAPL,Apple,100,200,20000\n",
    }) catch unreachable;

    // Write a schwab summary (non-CSV)
    tmp.dir.writeFile(io, .{
        .sub_path = "schwab.txt",
        .data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00\n",
    }) catch unreachable;

    // Write a random non-matching file
    tmp.dir.writeFile(io, .{
        .sub_path = "notes.txt",
        .data = "Just some random notes",
    }) catch unreachable;

    // Get the temp dir path
    const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable;
    defer allocator.free(tmp_path);

    // wall-clock required: test writes real files and verifies they're
    // treated as fresh. A fixed synthetic `now_s` would drift relative
    // to the file mtime and produce flaky results.
    const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
    const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s);
    defer {
        for (files) |f| allocator.free(f.path);
        allocator.free(files);
    }

    // Should find fidelity CSV and schwab summary, but not notes.txt
    try std.testing.expectEqual(@as(usize, 2), files.len);

    var found_fidelity = false;
    var found_schwab = false;
    for (files) |f| {
        switch (f.kind) {
            .fidelity_csv => found_fidelity = true,
            .schwab_summary => found_schwab = true,
            else => {},
        }
    }
    try std.testing.expect(found_fidelity);
    try std.testing.expect(found_schwab);
}

test "discoverBrokerFiles: empty directory returns empty" {
    const io = std.testing.io;
    const allocator = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable;
    defer allocator.free(tmp_path);

    const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
    const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s);
    defer allocator.free(files);

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

test "discoverBrokerFiles: nonexistent directory returns empty" {
    const io = std.testing.io;
    const allocator = std.testing.allocator;

    const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
    const files = try discoverBrokerFiles(io, allocator, "/nonexistent/path/audit", "test/", now_s);
    defer allocator.free(files);

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

test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
    var buf: [1024]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);

    const lot: contributions.UnmatchedLargeLot = .{
        .account = "Acct A",
        .symbol = "",
        .security_type = .cash,
        .value = 50_000.0,
        .open_date = Date.fromYmd(2026, 5, 10),
    };

    try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes
    const output = writer.buffered();

    // Header line with account + value + date.
    try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);

    // Template line with the expected SRF shape.
    try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
}

test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
    var buf: [1024]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);

    const lot: contributions.UnmatchedLargeLot = .{
        .account = "Acct B",
        .symbol = "SYM",
        .security_type = .stock,
        .value = 25_000.0,
        .open_date = Date.fromYmd(2026, 5, 3),
    };

    try printLargeLotWarning(&writer, lot, false);
    const output = writer.buffered();

    try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
}

test "printLargeLotWarning: cents are preserved in template" {
    // Regression: previously the template rounded to whole dollars,
    // so a $73,158.33 lot suggested `amount:num:73158`. Pasting that
    // verbatim into transaction_log.srf records a fictitious amount
    // and (with $1 matcher tolerance) only barely pairs. The fix
    // prints two-decimal precision so the suggested record exactly
    // describes the lot it's offering to attribute.
    var buf: [1024]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);

    const lot: contributions.UnmatchedLargeLot = .{
        .account = "Sample Trust",
        .symbol = "",
        .security_type = .cash,
        .value = 73_158.33,
        .open_date = Date.fromYmd(2026, 5, 20),
    };

    try printLargeLotWarning(&writer, lot, false);
    const output = writer.buffered();

    try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null);
    try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null);
}

test "strLessThan: orders strings lexicographically" {
    try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
    try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
    try std.testing.expect(!strLessThan({}, "AAPL", "AAPL"));
    try std.testing.expect(strLessThan({}, "AAPL", "AAPLE"));
}

test "lotToString: stock lot includes symbol, shares, date" {
    const allocator = std.testing.allocator;
    const lot = portfolio_mod.Lot{
        .symbol = "AAPL",
        .shares = 100,
        .open_date = Date.fromYmd(2024, 3, 15),
        .open_price = 150.50,
    };
    const s = try lotToString(allocator, lot);
    defer allocator.free(s);
    try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null);
    try std.testing.expect(std.mem.indexOf(u8, s, "100") != null);
    try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null);
}

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 = [_]SchwabAccountSummary{
        .{
            .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 = [_]SchwabAccountSummary{
        .{
            .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 = [_]SchwabAccountSummary{
        .{ .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 = [_]SchwabAccountSummary{
        .{
            .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 = [_]SchwabAccountSummary{
        .{
            .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 = [_]SchwabAccountSummary{
        .{
            .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);
    }
}
