const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");

/// Visibility of expired options / matured CDs within the Options
/// and CDs sections.
pub const ExpiredMode = enum {
    /// Collapse expired rows into a one-line per-section summary
    /// (the default).
    rollup,
    /// List every expired row (muted), alongside the active rows.
    show,
    /// Omit expired rows entirely.
    hide,

    fn parse(s: []const u8) ?ExpiredMode {
        if (std.mem.eql(u8, s, "rollup")) return .rollup;
        if (std.mem.eql(u8, s, "show")) return .show;
        if (std.mem.eql(u8, s, "hide")) return .hide;
        return null;
    }
};

pub const ParsedArgs = struct {
    expired: ExpiredMode = .rollup,
};

pub const meta: framework.Meta = .{
    .name = "portfolio",
    .group = .portfolio,
    .synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)",
    .help =
    \\Usage: zfin portfolio [--expired=rollup|show|hide]
    \\
    \\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol
    \\prices in parallel (server sync where ZFIN_SERVER is set,
    \\else providers), and print the position table + valuations
    \\+ historical-snapshot mini-tables. The watchlist (if
    \\`watchlist.srf` exists) is appended to the price-load step
    \\so its quotes show alongside.
    \\
    \\Expired options and matured CDs are collapsed into a one-line
    \\summary at the foot of their section by default (`--expired=rollup`).
    \\Use `--expired=show` to list them (muted) or `--expired=hide`
    \\to omit them entirely. Section TOTAL lines always reflect
    \\active holdings only.
    \\
    \\Refresh policy comes from the global `--refresh-data=<value>`
    \\flag (default: auto). Use `--refresh-data=force` to force a
    \\re-fetch of every symbol's candles, bypassing the per-symbol
    \\TTL freshness check. Use `--refresh-data=never` to serve
    \\cache contents only (offline mode).
    \\
    ,
    .uppercase_first_arg = false,
    .user_errors = error{ UnexpectedArg, InvalidExpiredValue },
};

pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
    var parsed: ParsedArgs = .{};
    for (cmd_args) |a| {
        if (std.mem.startsWith(u8, a, "--expired=")) {
            const val = a["--expired=".len..];
            parsed.expired = ExpiredMode.parse(val) orelse {
                cli.stderrPrint(ctx.io, "Error: invalid --expired value '");
                cli.stderrPrint(ctx.io, val);
                cli.stderrPrint(ctx.io, "' (expected: rollup, show, or hide)\n");
                return error.InvalidExpiredValue;
            };
            continue;
        }
        if (std.mem.eql(u8, a, "--expired")) {
            cli.stderrPrint(ctx.io, "Error: --expired requires a value (--expired=rollup|show|hide)\n");
            return error.InvalidExpiredValue;
        }
        if (std.mem.eql(u8, a, "--refresh")) {
            cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
            return error.UnexpectedArg;
        }
        cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
        cli.stderrPrint(ctx.io, a);
        cli.stderrPrint(ctx.io, "\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 wl = ctx.resolveWatchlistPath();
    defer wl.deinit(allocator);
    const watchlist_path: ?[]const u8 =
        if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null;

    // Resolve patterns + load (and union-merge) every matched
    // portfolio file in one shot. The returned LoadedPortfolio
    // carries the resolved paths so we can derive an anchor and
    // a multi-file display label without a second resolver call.
    var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
    defer loaded.deinit(allocator);

    const portfolio = loaded.portfolio;
    const positions = loaded.positions;
    const syms = loaded.syms;
    const anchor_path = loaded.anchor();

    if (portfolio.lots.len == 0) {
        cli.stderrPrint(io, "Portfolio is empty.\n");
        return;
    }

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

    var fail_count: usize = 0;

    // Also collect watch symbols that need fetching
    var watch_syms: std.ArrayList([]const u8) = .empty;
    defer watch_syms.deinit(allocator);
    {
        var seen = std.StringHashMap(void).init(allocator);
        defer seen.deinit();
        for (syms) |s| try seen.put(s, {});
        for (portfolio.lots) |lot| {
            if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
                try seen.put(lot.priceSymbol(), {});
                try watch_syms.append(allocator, lot.priceSymbol());
            }
        }
    }

    // All symbols to fetch (stock positions + watch)
    const all_syms_count = syms.len + watch_syms.items.len;

    if (all_syms_count > 0) {
        // Use consolidated parallel loader
        var load_result = cli.loadPortfolioPrices(
            io,
            svc,
            syms,
            watch_syms.items,
            ctx.globals.refresh_policy,
            color,
        );
        defer load_result.deinit(); // Free the prices hashmap after we copy

        // Transfer prices to our local map
        var it = load_result.prices.iterator();
        while (it.next()) |entry| {
            try prices.put(entry.key_ptr.*, entry.value_ptr.*);
        }
        fail_count = load_result.failed_count;
    }

    // Build portfolio summary, candle map, and historical snapshots
    var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
        error.NoAllocations, error.SummaryFailed => {
            cli.stderrPrint(io, "Error computing portfolio summary.\n");
            return;
        },
        else => return err,
    };
    defer pf_data.deinit(allocator);

    // Sort allocations alphabetically by symbol
    std.mem.sort(zfin.valuation.Allocation, pf_data.summary.allocations, {}, struct {
        fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
            return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
        }
    }.f);

    // Collect watch symbols and their prices for display.
    // Includes watch lots from portfolio + symbols from separate watchlist file.
    var watch_list: std.ArrayList([]const u8) = .empty;
    defer watch_list.deinit(allocator);
    var watch_prices = std.StringHashMap(f64).init(allocator);
    defer watch_prices.deinit();
    {
        var watch_seen = std.StringHashMap(void).init(allocator);
        defer watch_seen.deinit();
        // Exclude portfolio position symbols from watchlist
        for (pf_data.summary.allocations) |a| {
            try watch_seen.put(a.symbol, {});
        }

        // Watch lots from portfolio
        for (portfolio.lots) |lot| {
            if (lot.security_type == .watch) {
                const sym = lot.priceSymbol();
                if (watch_seen.contains(sym)) continue;
                try watch_seen.put(sym, {});
                try watch_list.append(allocator, sym);
                if (svc.getCachedLastClose(sym)) |close| {
                    try watch_prices.put(sym, close);
                }
            }
        }

        // Separate watchlist file (backward compat)
        if (watchlist_path) |wl_path| {
            const wl_syms = cli.loadWatchlist(io, allocator, wl_path);
            defer cli.freeWatchlist(allocator, wl_syms);
            if (wl_syms) |syms_list| {
                for (syms_list) |sym| {
                    if (watch_seen.contains(sym)) continue;
                    try watch_seen.put(sym, {});
                    try watch_list.append(allocator, sym);
                    if (svc.getCachedLastClose(sym)) |close| {
                        try watch_prices.put(sym, close);
                    }
                }
            }
        }
    }

    // Build a display label: anchor file plus "(+N more)" when
    // multiple portfolio files contributed lots. Keeps the existing
    // single-file UX unchanged while making multi-file mode visible.
    var label_buf: [512]u8 = undefined;
    const display_label: []const u8 = if (loaded.paths.len > 1)
        std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
    else
        anchor_path;

    try display(
        allocator,
        out,
        color,
        display_label,
        &portfolio,
        positions,
        &pf_data,
        watch_list.items,
        watch_prices,
        as_of,
        parsed.expired,
    );
}

/// Render the full portfolio display. All data is pre-fetched; no service calls.
pub fn display(
    allocator: std.mem.Allocator,
    out: *std.Io.Writer,
    color: bool,
    file_path: []const u8,
    portfolio: *const zfin.Portfolio,
    positions: []const zfin.Position,
    pf_data: *const cli.PortfolioData,
    watch_symbols: []const []const u8,
    watch_prices: std.StringHashMap(f64),
    as_of: zfin.Date,
    expired_mode: ExpiredMode,
) !void {
    const summary = &pf_data.summary;
    // Header with summary
    try cli.printBold(out, color, "\nPortfolio Summary ({s})\n", .{file_path});
    try out.print("========================================\n", .{});

    // Summary bar
    {
        const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
        try out.print("  Value: {f}  Cost: {f}  ", .{ Money.from(summary.total_value), Money.from(summary.total_cost) });
        try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "Gain/Loss: {c}{f} ({d:.1}%)", .{
            @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
            Money.from(gl_abs),
            summary.unrealized_return * 100.0,
        });
        try out.print("\n", .{});
    }

    // Lot counts (stocks/ETFs only)
    var open_lots: u32 = 0;
    var closed_lots: u32 = 0;
    for (portfolio.lots) |lot| {
        if (lot.security_type != .stock) continue;
        if (lot.isOpen(as_of)) open_lots += 1 else closed_lots += 1;
    }
    try cli.printFg(out, color, cli.CLR_MUTED, "  Lots: {d} open, {d} closed    Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });

    // Historical portfolio value snapshots
    {
        if (pf_data.snapshots) |snapshots| {
            try out.print("  Historical: ", .{});
            try cli.setFg(out, color, cli.CLR_MUTED);
            for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
                const snap = snapshots[pi];
                var hbuf: [16]u8 = undefined;
                const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
                if (snap.position_count > 0) try cli.setGainLoss(out, color, snap.changePct());
                try out.print(" {s}: {s}", .{ period.label(), change_str });
                if (pi < zfin.valuation.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
            }
            try cli.reset(out, color);
            try out.print("\n", .{});
        }
    }

    // Column headers
    try out.print("\n", .{});
    try cli.setFg(out, color, cli.CLR_MUTED);
    try out.print("  " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
        "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
    });
    try out.print("  " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
        "", "", "", "", "", "", "", "", "",
    });
    try cli.reset(out, color);

    // Position rows with lot detail
    for (summary.allocations) |a| {
        // Count stock lots for this symbol
        var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
        defer lots_for_sym.deinit(allocator);
        for (portfolio.lots) |lot| {
            if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
                try lots_for_sym.append(allocator, lot);
            }
        }
        std.mem.sort(zfin.Lot, lots_for_sym.items, as_of, fmt.lotSortFn);
        const is_multi = lots_for_sym.items.len > 1;

        // Position summary row
        {
            const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
            const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";

            // Date + ST/LT for single-lot positions
            var date_col: [24]u8 = @splat(' ');
            var date_col_len: usize = 0;
            if (!is_multi and lots_for_sym.items.len == 1) {
                const lot = lots_for_sym.items[0];
                const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
                const written = std.fmt.bufPrint(&date_col, "{f} {s}", .{ lot.open_date, indicator }) catch "";
                date_col_len = written.len;
            }

            if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
            try out.print("  " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} ", .{
                a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(10),
            });
            try out.print("{f}", .{Money.from(a.current_price).padRight(10)});
            try out.print(" {f} ", .{Money.from(a.market_value).padRight(16)});
            try cli.setGainLoss(out, color, a.unrealized_gain_loss);
            try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(13) });
            if (a.is_manual_price) {
                try cli.setFg(out, color, cli.CLR_WARNING);
            } else {
                try cli.reset(out, color);
            }
            try out.print(" {d:>7.1}%", .{a.weight * 100.0});
            if (date_col_len > 0) {
                try out.print(" {s}", .{date_col[0..date_col_len]});
            }
            // Account for single-lot
            if (!is_multi and lots_for_sym.items.len == 1) {
                if (lots_for_sym.items[0].account) |acct| {
                    try out.print(" {s}", .{acct});
                }
            }
            if (a.is_manual_price) try cli.reset(out, color);
            try out.print("\n", .{});
        }

        // Lot detail rows (always expanded for CLI)
        if (is_multi) {
            // Check if any lots are DRIP
            var has_drip = false;
            for (lots_for_sym.items) |lot| {
                if (lot.drip) {
                    has_drip = true;
                    break;
                }
            }

            if (!has_drip) {
                // No DRIP: show all individually
                for (lots_for_sym.items) |lot| {
                    try printLotRow(as_of, out, color, lot, a.current_price);
                }
            } else {
                // Show non-DRIP lots individually
                for (lots_for_sym.items) |lot| {
                    if (!lot.drip) {
                        try printLotRow(as_of, out, color, lot, a.current_price);
                    }
                }

                // Summarize DRIP lots as ST/LT
                const drip = fmt.aggregateDripLots(as_of, lots_for_sym.items);

                if (!drip.st.isEmpty()) {
                    var drip_buf: [128]u8 = undefined;
                    try cli.printFg(out, color, cli.CLR_MUTED, "    {s}\n", .{fmt.fmtDripSummary(&drip_buf, "ST", drip.st)});
                }
                if (!drip.lt.isEmpty()) {
                    var drip_buf2: [128]u8 = undefined;
                    try cli.printFg(out, color, cli.CLR_MUTED, "    {s}\n", .{fmt.fmtDripSummary(&drip_buf2, "LT", drip.lt)});
                }
            }
        }
    }

    // Totals line
    try cli.printFg(out, color, cli.CLR_MUTED, "  {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
        "", "", "", "", "", "", "",
    });
    {
        const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
        try out.print("  {s:>6} {s:>8} {s:>10} {s:>10} {f} ", .{
            "", "", "", "TOTAL", Money.from(summary.total_value).padRight(16),
        });
        try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{
            @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
            Money.from(gl_abs).padRight(13),
        });
        try out.print(" {s:>7}\n", .{"100.0%"});
    }

    if (summary.realized_gain_loss != 0) {
        const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
        try cli.printGainLoss(out, color, summary.realized_gain_loss, "\n  Realized P&L:     {c}{f}\n", .{
            @as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'),
            Money.from(rpl_abs),
        });
    }

    // Options section
    if (portfolio.hasType(.option)) {
        var prepared_opts = try views.Options.init(as_of, allocator, portfolio.lots, null);
        defer prepared_opts.deinit();
        const active = prepared_opts.activeItems();
        const expired = prepared_opts.expiredItems();
        // `show` lists every row; `rollup`/`hide` list active only.
        const opt_rows = if (expired_mode == .show) prepared_opts.items else active;
        const show_rollup = expired_mode == .rollup and expired.len > 0;
        if (opt_rows.len > 0 or show_rollup) {
            try out.print("\n", .{});
            try cli.printBold(out, color, "  Options\n", .{});
            if (opt_rows.len > 0) {
                try cli.setFg(out, color, cli.CLR_MUTED);
                try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels);
                try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills);
                try cli.reset(out, color);
            }

            // TOTAL reflects active premium only, regardless of mode.
            var opt_total_premium: f64 = 0;
            for (active) |po| opt_total_premium += po.premium;
            for (opt_rows) |po| {
                const text = po.columns[0].text;
                const prem_start = po.premium_col_start;
                const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len);
                // Pre-premium portion
                try cli.printIntent(out, color, po.row_style, "{s}", .{text[0..prem_start]});
                // Premium column
                try cli.printIntent(out, color, po.premium_style, "{s}", .{text[prem_start..prem_end]});
                // Post-premium portion (account)
                if (prem_end < text.len) try cli.printIntent(out, color, po.row_style, "{s}", .{text[prem_end..]});
                try out.print("\n", .{});
            }
            // Options total (active only). 4-space prefix matches the
            // section's data rows (OptionsLayout.prefix).
            if (active.len > 0) {
                try cli.printFg(out, color, cli.CLR_MUTED, "    {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
                try out.print("    {s:>30} {s:>6} {s:>12} {f}\n", .{
                    "", "", "TOTAL", Money.from(opt_total_premium).padRight(14),
                });
            }
            // Rolled-up expired summary (count only).
            if (show_rollup) {
                const noun: []const u8 = if (expired.len == 1) "expired option" else "expired options";
                try cli.printIntent(out, color, .muted, "  {d} {s}  (--expired=show)\n", .{ expired.len, noun });
            }
        }
    }

    // CDs section
    if (portfolio.hasType(.cd)) {
        var prepared_cds = try views.CDs.init(as_of, allocator, portfolio.lots, null);
        defer prepared_cds.deinit();
        const active = prepared_cds.activeItems();
        const expired = prepared_cds.expiredItems();
        const cd_rows = if (expired_mode == .show) prepared_cds.items else active;
        const show_rollup = expired_mode == .rollup and expired.len > 0;
        if (cd_rows.len > 0 or show_rollup) {
            try out.print("\n", .{});
            try cli.printBold(out, color, "  Certificates of Deposit\n", .{});
            if (cd_rows.len > 0) {
                try cli.setFg(out, color, cli.CLR_MUTED);
                try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels);
                try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills);
                try cli.reset(out, color);
            }

            // TOTAL reflects active face value only, regardless of mode.
            var cd_section_total: f64 = 0;
            for (active) |pc| cd_section_total += pc.lot.shares;
            for (cd_rows) |pc| {
                try cli.printIntent(out, color, pc.row_style, "{s}\n", .{pc.text});
            }
            // CD total (active only). 4-space prefix matches the
            // section's data rows (CDsLayout.prefix).
            if (active.len > 0) {
                try cli.printFg(out, color, cli.CLR_MUTED, "    {s:->12} {s:->14}\n", .{ "", "" });
                try out.print("    {s:>12} {f}\n", .{
                    "TOTAL", Money.from(cd_section_total).padRight(14),
                });
            }
            // Rolled-up matured summary (count only).
            if (show_rollup) {
                const noun: []const u8 = if (expired.len == 1) "matured CD" else "matured CDs";
                try cli.printIntent(out, color, .muted, "  {d} {s}  (--expired=show)\n", .{ expired.len, noun });
            }
        }
    }

    // Cash section
    if (portfolio.hasType(.cash)) {
        try out.print("\n", .{});
        try cli.printBold(out, color, "  Cash\n", .{});
        try cli.setFg(out, color, cli.CLR_MUTED);
        var cash_hdr_buf: [80]u8 = undefined;
        try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)});
        var cash_sep_buf: [80]u8 = undefined;
        try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
        try cli.reset(out, color);

        for (portfolio.lots) |lot| {
            if (lot.security_type != .cash) continue;
            const acct2: []const u8 = lot.account orelse "Unknown";
            var row_buf: [160]u8 = undefined;
            try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
        }
        // Cash total
        var sep_buf: [80]u8 = undefined;
        try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{fmt.fmtCashSep(&sep_buf)});
        var total_buf: [80]u8 = undefined;
        try cli.printBold(out, color, "{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash(as_of))});
    }

    // Illiquid assets section
    if (portfolio.hasType(.illiquid)) {
        try out.print("\n", .{});
        try cli.printBold(out, color, "  Illiquid Assets\n", .{});
        try cli.setFg(out, color, cli.CLR_MUTED);
        var il_hdr_buf: [80]u8 = undefined;
        try out.print("{s}\n", .{fmt.fmtIlliquidHeader(&il_hdr_buf)});
        var il_sep_buf1: [80]u8 = undefined;
        try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
        try cli.reset(out, color);

        for (portfolio.lots) |lot| {
            if (lot.security_type != .illiquid) continue;
            var il_row_buf: [160]u8 = undefined;
            try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)});
        }
        // Illiquid total
        var il_sep_buf2: [80]u8 = undefined;
        try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)});
        var il_total_buf: [80]u8 = undefined;
        try cli.printBold(out, color, "{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid(as_of))});
    }

    // Net Worth (if illiquid assets exist)
    if (portfolio.hasType(.illiquid)) {
        const illiquid_total = portfolio.totalIlliquid(as_of);
        const net_worth = zfin.valuation.netWorth(as_of, portfolio.*, summary.*);
        try out.print("\n", .{});
        try cli.printBold(out, color, "  Net Worth: {f}  (Liquid: {f}  Illiquid: {f})\n", .{
            Money.from(net_worth),
            Money.from(summary.total_value),
            Money.from(illiquid_total),
        });
    }

    // Watchlist
    if (watch_symbols.len > 0) {
        try out.print("\n", .{});
        try cli.printBold(out, color, "  Watchlist:\n", .{});
        for (watch_symbols) |sym| {
            var price_str: [16]u8 = undefined;
            const ps: []const u8 = if (watch_prices.get(sym)) |close|
                std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?"
            else
                "--";
            try out.print("  " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps });
        }
    }

    // Per-symbol risk metrics (vol / Sharpe / max drawdown) used to
    // print here. They moved to `zfin review`, which combines them
    // with trailing returns + sector + tax-status into a single
    // per-holding dashboard. The portfolio command now sticks to its
    // job: positions + valuations + watchlist.

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

pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void {
    const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
    const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed";
    const acct_col: []const u8 = lot.account orelse "";

    const use_price = lot.close_price orelse current_price;
    const gl = lot.shares * (use_price - lot.open_price);
    const lot_gl_abs = if (gl >= 0) gl else -gl;
    const lot_sign: []const u8 = if (gl >= 0) "+" else "-";

    try cli.printFg(out, color, cli.CLR_MUTED, "    " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} {s:>10} {f} ", .{
        status_str, lot.shares, Money.from(lot.open_price).padRight(10), "", Money.from(lot.shares * use_price).padRight(16),
    });
    try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(13) });
    try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col });
}

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

const testing = std.testing;

/// Helper: build a minimal portfolio for testing.
/// Returns lots as a stack-allocated array and a Portfolio that references them.
/// Caller must NOT call deinit() since lots are stack-allocated.
fn testPortfolio(lots: []const zfin.Lot) zfin.Portfolio {
    return .{
        .lots = @constCast(lots),
        .allocator = testing.allocator,
    };
}

fn testSummary(allocations: []zfin.valuation.Allocation) zfin.valuation.PortfolioSummary {
    var total_value: f64 = 0;
    var total_cost: f64 = 0;
    var unrealized_gain_loss: f64 = 0;
    for (allocations) |a| {
        total_value += a.market_value;
        total_cost += a.cost_basis;
        unrealized_gain_loss += a.unrealized_gain_loss;
    }
    return .{
        .total_value = total_value,
        .total_cost = total_cost,
        .unrealized_gain_loss = unrealized_gain_loss,
        .unrealized_return = if (total_cost > 0) unrealized_gain_loss / total_cost else 0,
        .realized_gain_loss = 0,
        .allocations = allocations,
    };
}

fn testPortfolioData(summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle)) cli.PortfolioData {
    return .{
        .summary = summary,
        .candle_map = candle_map,
        .snapshots = null,
    };
}

test "display shows header and summary" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    var lots = [_]zfin.Lot{
        .{ .symbol = "AAPL", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 15), .open_price = 150.0 },
        .{ .symbol = "GOOG", .shares = 5, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 120.0 },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
        .{ .symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .total_cost = 600.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };

    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 },
        .{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 },
    };
    const summary = testSummary(&allocs);

    var prices = std.StringHashMap(f64).init(testing.allocator);
    defer prices.deinit();
    try prices.put("AAPL", 175.0);
    try prices.put("GOOG", 140.0);

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);

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

    const watch_syms: []const []const u8 = &.{};

    try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
    const out = w.buffered();

    // Header present
    try testing.expect(std.mem.indexOf(u8, out, "Portfolio Summary (test.srf)") != null);
    // Symbols present
    try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
    try testing.expect(std.mem.indexOf(u8, out, "GOOG") != null);
    // Column headers present
    try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Market Value") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Gain/Loss") != null);
    // TOTAL line present
    try testing.expect(std.mem.indexOf(u8, out, "TOTAL") != null);
    try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null);
    // No ANSI codes
    try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

test "display with watchlist" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 20, .open_date = zfin.Date.fromYmd(2022, 3, 1), .open_price = 200.0 },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "VTI", .shares = 20, .avg_cost = 200.0, .total_cost = 4000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };

    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 },
    };
    const summary = testSummary(&allocs);

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

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);

    // Watchlist with prices
    const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" };
    var watch_prices = std.StringHashMap(f64).init(testing.allocator);
    defer watch_prices.deinit();
    try watch_prices.put("TSLA", 250.50);
    try watch_prices.put("NVDA", 800.25);

    try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
    const out = w.buffered();

    // Watchlist header and symbols
    try testing.expect(std.mem.indexOf(u8, out, "Watchlist:") != null);
    try testing.expect(std.mem.indexOf(u8, out, "TSLA") != null);
    try testing.expect(std.mem.indexOf(u8, out, "NVDA") != null);
}

test "display with options section" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    var lots = [_]zfin.Lot{
        .{ .symbol = "SPY", .shares = 50, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 400.0 },
        .{ .symbol = "SPY 240119C00450000", .shares = 2, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 5.50, .security_type = .option },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "SPY", .shares = 50, .avg_cost = 400.0, .total_cost = 20000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };

    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "SPY", .display_symbol = "SPY", .shares = 50, .avg_cost = 400.0, .current_price = 450.0, .market_value = 22500.0, .cost_basis = 20000.0, .weight = 1.0, .unrealized_gain_loss = 2500.0, .unrealized_return = 0.125 },
    };
    var summary = testSummary(&allocs);
    // Include option cost in totals (like run() does)
    summary.total_value += portfolio.totalOptionCost(zfin.Date.fromYmd(2026, 5, 8));
    summary.total_cost += portfolio.totalOptionCost(zfin.Date.fromYmd(2026, 5, 8));

    var prices = std.StringHashMap(f64).init(testing.allocator);
    defer prices.deinit();
    try prices.put("SPY", 450.0);

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);
    var watch_prices = std.StringHashMap(f64).init(testing.allocator);
    defer watch_prices.deinit();
    const watch_syms: []const []const u8 = &.{};

    try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
    const out = w.buffered();

    // Options section present
    try testing.expect(std.mem.indexOf(u8, out, "Options") != null);
    try testing.expect(std.mem.indexOf(u8, out, "SPY 240119C00450000") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Cost/Ctrct") != null);
}

test "display with CDs and cash" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
        .{ .symbol = "912828ZT0", .shares = 10000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 100.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2027, 6, 15) },
        .{ .symbol = "CASH", .shares = 5000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 0, .security_type = .cash, .account = "Brokerage" },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };

    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
    };
    var summary = testSummary(&allocs);
    summary.total_value += portfolio.totalCash(zfin.Date.fromYmd(2026, 5, 8)) + portfolio.totalCdFaceValue(zfin.Date.fromYmd(2026, 5, 8));
    summary.total_cost += portfolio.totalCash(zfin.Date.fromYmd(2026, 5, 8)) + portfolio.totalCdFaceValue(zfin.Date.fromYmd(2026, 5, 8));

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

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);
    var watch_prices = std.StringHashMap(f64).init(testing.allocator);
    defer watch_prices.deinit();
    const watch_syms: []const []const u8 = &.{};

    try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
    const out = w.buffered();

    // CDs section present
    try testing.expect(std.mem.indexOf(u8, out, "Certificates of Deposit") != null);
    try testing.expect(std.mem.indexOf(u8, out, "912828ZT0") != null);
    try testing.expect(std.mem.indexOf(u8, out, "4.50%") != null);

    // Cash section present
    try testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
    try testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null);
}

test "display realized PnL shown when nonzero" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    var lots = [_]zfin.Lot{
        .{ .symbol = "MSFT", .shares = 10, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 300.0 },
        .{ .symbol = "MSFT", .shares = 5, .open_date = zfin.Date.fromYmd(2022, 6, 1), .open_price = 280.0, .close_date = zfin.Date.fromYmd(2023, 6, 1), .close_price = 350.0 },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .total_cost = 3000.0, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 350.0 },
    };

    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "MSFT", .display_symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .current_price = 400.0, .market_value = 4000.0, .cost_basis = 3000.0, .weight = 1.0, .unrealized_gain_loss = 1000.0, .unrealized_return = 0.333 },
    };
    var summary = testSummary(&allocs);
    summary.realized_gain_loss = 350.0;

    var prices = std.StringHashMap(f64).init(testing.allocator);
    defer prices.deinit();
    try prices.put("MSFT", 400.0);

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);
    var watch_prices = std.StringHashMap(f64).init(testing.allocator);
    defer watch_prices.deinit();
    const watch_syms: []const []const u8 = &.{};

    try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
    const out = w.buffered();

    try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null);
}

test "display empty watchlist not shown" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);

    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };

    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
    };
    const summary = testSummary(&allocs);

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

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);
    var watch_prices = std.StringHashMap(f64).init(testing.allocator);
    defer watch_prices.deinit();
    const watch_syms: []const []const u8 = &.{};

    try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
    const out = w.buffered();

    // Watchlist header should NOT appear when there are no watch symbols
    try testing.expect(std.mem.indexOf(u8, out, "Watchlist") == null);
}

// ── expired-mode display tests ─────────────────────────────────

/// Build a fixture with one active + one expired option, one active
/// + one matured CD (plus a stock position), and render it via
/// `display` in the given mode into `w`. as_of = 2026-05-08.
fn renderExpiredFixture(w: *std.Io.Writer, mode: ExpiredMode) !void {
    var lots = [_]zfin.Lot{
        .{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
        // active option (future expiry); sold-to-open
        .{ .symbol = "OPT-ACTIVE", .shares = -1, .open_date = zfin.Date.fromYmd(2026, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = zfin.Date.fromYmd(2026, 12, 1) },
        // expired option (past expiry)
        .{ .symbol = "OPT-EXPIRED", .shares = 1, .open_date = zfin.Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .maturity_date = zfin.Date.fromYmd(2024, 6, 1) },
        // active CD (future maturity); $10,000 face
        .{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2027, 6, 15) },
        // matured CD (past maturity); $5,000 face
        .{ .symbol = "CD-MATURED", .shares = 5000, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .rate = 3.0, .maturity_date = zfin.Date.fromYmd(2024, 6, 15) },
    };
    var portfolio = testPortfolio(&lots);

    var positions = [_]zfin.Position{
        .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
    };
    var allocs = [_]zfin.valuation.Allocation{
        .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
    };
    const summary = testSummary(&allocs);

    var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
    defer candle_map.deinit();
    const pf_data = testPortfolioData(summary, candle_map);
    var watch_prices = std.StringHashMap(f64).init(testing.allocator);
    defer watch_prices.deinit();
    const watch_syms: []const []const u8 = &.{};

    try display(testing.allocator, w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), mode);
}

test "display: expired options/CDs are rolled up by default" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderExpiredFixture(&w, .rollup);
    const out = w.buffered();

    // Active rows visible.
    try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null);
    try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null);
    // Expired/matured rows hidden.
    try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") == null);
    try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") == null);
    // One-line rollup summaries with a hint.
    try testing.expect(std.mem.indexOf(u8, out, "1 expired option") != null);
    try testing.expect(std.mem.indexOf(u8, out, "1 matured CD") != null);
    try testing.expect(std.mem.indexOf(u8, out, "--expired=show") != null);
    // CD TOTAL is active-only ($10,000, not $15,000).
    try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null);
    try testing.expect(std.mem.indexOf(u8, out, "$15,000.00") == null);
}

test "display: --expired=show lists expired rows, no rollup line" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderExpiredFixture(&w, .show);
    const out = w.buffered();

    // Every row visible, active and expired alike.
    try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null);
    try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") != null);
    try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null);
    try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") != null);
    // No rollup summary in show mode.
    try testing.expect(std.mem.indexOf(u8, out, "expired option") == null);
    try testing.expect(std.mem.indexOf(u8, out, "matured CD") == null);
    // TOTAL still active-only.
    try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null);
    try testing.expect(std.mem.indexOf(u8, out, "$15,000.00") == null);
}

test "display: --expired=hide omits expired rows entirely" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderExpiredFixture(&w, .hide);
    const out = w.buffered();

    // Active rows visible.
    try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null);
    try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null);
    // Expired rows and rollup summaries both gone.
    try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") == null);
    try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") == null);
    try testing.expect(std.mem.indexOf(u8, out, "expired option") == null);
    try testing.expect(std.mem.indexOf(u8, out, "matured CD") == null);
    // TOTAL active-only.
    try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null);
}

test "display: section TOTAL line aligns with its data rows" {
    var buf: [8192]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try renderExpiredFixture(&w, .rollup);
    const out = w.buffered();

    // The active CD face ($10,000.00) shows in both the CD-ACTIVE data
    // row and the CD section TOTAL line; an aligned total puts the value
    // at the same column in both. (Before the prefix fix the TOTAL was
    // shifted 2 columns left.)
    var first_col: ?usize = null;
    var count: usize = 0;
    var it = std.mem.splitScalar(u8, out, '\n');
    while (it.next()) |line| {
        if (std.mem.indexOf(u8, line, "$10,000.00")) |col| {
            count += 1;
            if (first_col) |fc| try testing.expectEqual(fc, col) else first_col = col;
        }
    }
    try testing.expectEqual(@as(usize, 2), count);
}

// ── parseArgs tests ────────────────────────────────────────────

test "parseArgs: no args returns default ParsedArgs" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(ExpiredMode.rollup, parsed.expired);
}

test "parseArgs: --refresh rejected (now a global)" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--refresh"};
    try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}

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

test "parseArgs: --expired=show parses" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--expired=show"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(ExpiredMode.show, parsed.expired);
}

test "parseArgs: --expired=hide parses" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--expired=hide"};
    const parsed = try parseArgs(&ctx, &args);
    try std.testing.expectEqual(ExpiredMode.hide, parsed.expired);
}

test "parseArgs: --expired with bad value errors" {
    var ctx: framework.RunCtx = undefined;
    ctx.io = std.testing.io;
    const args = [_][]const u8{"--expired=bogus"};
    try std.testing.expectError(error.InvalidExpiredValue, parseArgs(&ctx, &args));
}

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