//! View models for portfolio sections (Positions, Options, CDs).
//! Produces renderer-agnostic display data consumed by both CLI and TUI.
//! Column widths, format strings, computed values, and style decisions
//! are defined here. Renderers are thin adapters that map StyleIntent
//! to platform-specific styles and emit pre-formatted text.

const std = @import("std");
const Lot = @import("../models/portfolio.zig").Lot;
const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const Allocation = @import("../analytics/valuation.zig").Allocation;

// ── Positions (main holdings table) ───────────────────────────

/// Fixed knobs for the main holdings table. The numeric column widths
/// are NOT fixed any more: they're computed per render by
/// `computeWidths` so the table fits its content (each column's header
/// label is the floor; the widest rendered datum is the ceiling). This
/// struct holds only the parts that never vary:
///
///   - The two columns that never need to grow: Weight (bounded to
///     "100.0%") and Date (always "YYYY-MM-DD" plus a one-col ST/LT
///     indicator).
///   - The minimum width of each dynamic column, which equals the
///     width of that column's header label so the label is never
///     truncated (even for an empty portfolio).
///   - The fixed weight/date format specs and the header labels.
///
/// `PositionsWidths` (via `computeWidths`) is the single source of
/// truth for the dynamic widths. It's consumed by the CLI
/// (commands/portfolio.zig), the TUI rows + header
/// (tui/portfolio_tab.zig), and the TUI click-to-sort hit-test offsets,
/// so they all size identically and can't drift apart.
pub const PositionsLayout = struct {
    const cp = std.fmt.comptimePrint;

    // Fixed columns (never grow). The 1-col separating space between
    // columns is added at layout time, not included here.
    pub const weight_w = 8; // "NNN.N%"
    pub const date_w = 13; // "YYYY-MM-DD" + " " + ST/LT indicator
    pub const account_w = 8; // separator rule only; header/data are natural width

    // Minimum width of each dynamic column = its header label width, so
    // the label never truncates. (min_symbol_w also covers the lot-row
    // status text "closed".)
    pub const min_symbol_w = "Symbol".len; // 6
    pub const min_shares_w = "Shares".len; // 6
    pub const min_price_w = "Avg Cost".len; // 8 (wider of "Avg Cost" / "Price")
    pub const min_value_w = "Market Value".len; // 12
    pub const min_gainloss_w = "Gain/Loss".len; // 9

    // Fixed-column format specs (weight + date never vary). `weight_num`
    // renders a float in place (data rows); `weight_str` right-justifies
    // a pre-formatted string (headers, the TUI's pre-rendered cells);
    // `date_str` left-justifies the date text.
    pub const weight_num = cp("{{d:>{d}.1}}%", .{weight_w - 1});
    pub const weight_str = cp("{{s:>{d}}}", .{weight_w});
    pub const date_str = cp("{{s:<{d}}}", .{date_w});

    pub const header_labels = .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account" };
};

/// Per-render widths for the holdings table's dynamic columns, plus the
/// two fixed ones mirrored in so callers have one place that answers
/// "how wide is column X". Field defaults are the per-column minimums,
/// so a default-initialized value (e.g. a TUI state's cached widths
/// before the first render) still yields a valid, non-truncating
/// layout. Populate via `computeWidths`.
pub const PositionsWidths = struct {
    symbol_w: usize = PositionsLayout.min_symbol_w,
    shares_w: usize = PositionsLayout.min_shares_w,
    price_w: usize = PositionsLayout.min_price_w,
    value_w: usize = PositionsLayout.min_value_w,
    gainloss_w: usize = PositionsLayout.min_gainloss_w,
    weight_w: usize = PositionsLayout.weight_w,
    date_w: usize = PositionsLayout.date_w,
};

/// Compute the holdings-table column widths from the data that will be
/// rendered: every allocation row, every stock lot row (lot
/// `open_price` lands in the Avg Cost column, and a single lot's
/// gain/loss can exceed the netted position/total gain/loss when lots
/// have opposite signs, so both must be observed), the TOTAL row, and
/// the watchlist rows (which share the Symbol / Price columns). Each
/// width starts at its header-label minimum and grows to fit.
///
/// `watch_prices` is keyed by symbol; absent prices simply don't widen
/// the Price column. Pass an empty `watch_syms` to exclude the
/// watchlist (the TUI hides it under an account filter).
///
/// Note: callers that show a filtered subset (the TUI account filter)
/// may pass the full, unfiltered allocations here. That only ever
/// over-estimates a column (a hidden wide row can't be under-padded),
/// so alignment stays correct - the filtered view is at worst slightly
/// roomier than strictly necessary.
pub fn computeWidths(
    allocations: []const Allocation,
    lots: []const Lot,
    total_value: f64,
    total_gl: f64,
    watch_syms: []const []const u8,
    watch_prices: ?std.StringHashMap(f64),
) PositionsWidths {
    var w: PositionsWidths = .{};

    for (allocations) |a| {
        w.symbol_w = @max(w.symbol_w, fmt.displayCols(a.display_symbol));
        w.shares_w = @max(w.shares_w, sharesCols(a.shares));
        w.price_w = @max(w.price_w, moneyCols(a.avg_cost));
        w.price_w = @max(w.price_w, moneyCols(a.current_price));
        w.value_w = @max(w.value_w, moneyCols(a.market_value));
        w.gainloss_w = @max(w.gainloss_w, gainLossCols(a.unrealized_gain_loss));
    }

    for (lots) |lot| {
        if (lot.security_type != .stock) continue;
        w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
        w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
        const use_price = lot.close_price orelse currentPriceFor(allocations, lot.priceSymbol());
        w.value_w = @max(w.value_w, moneyCols(lot.effectiveShares() * use_price));
        w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.effectiveShares() * (use_price - lot.effectiveOpenPrice())));
    }

    w.value_w = @max(w.value_w, moneyCols(total_value));
    w.gainloss_w = @max(w.gainloss_w, gainLossCols(total_gl));

    for (watch_syms) |sym| {
        w.symbol_w = @max(w.symbol_w, fmt.displayCols(sym));
        if (watch_prices) |wp| {
            if (wp.get(sym)) |price| w.price_w = @max(w.price_w, moneyCols(price));
        }
    }

    return w;
}

/// Display columns of the default Money rendering of `amount`
/// ("$1,234.56"). Money output is ASCII, so byte length == columns.
fn moneyCols(amount: f64) usize {
    var buf: [48]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    // 48 bytes covers any realistic dollar amount; the fixed writer
    // returns WriteFailed rather than overflowing, in which case we
    // fall back to the buffer length (an over-estimate, never an
    // under-pad).
    Money.from(amount).format(&w) catch return buf.len;
    return w.buffered().len;
}

/// Display columns of the shares rendering ("{d:.1}").
fn sharesCols(shares: f64) usize {
    var buf: [48]u8 = undefined;
    const s = std.fmt.bufPrint(&buf, "{d:.1}", .{shares}) catch return buf.len;
    return s.len;
}

/// Display columns of a gain/loss cell: one sign char + the money
/// magnitude.
fn gainLossCols(amount: f64) usize {
    return 1 + moneyCols(if (amount < 0) -amount else amount);
}

/// Current price for `symbol` from the allocations (linear scan;
/// portfolios are small). Returns 0 when absent, which yields a
/// trivially small cell that widens nothing.
fn currentPriceFor(allocations: []const Allocation, symbol: []const u8) f64 {
    for (allocations) |a| {
        if (std.mem.eql(u8, a.symbol, symbol)) return a.current_price;
    }
    return 0;
}

/// Write `content` into a `width`-column field: right-justified (spaces
/// before) when `right`, else left-justified (spaces after).
/// Display-column aware. The streaming analogue of
/// `format.padLeftToCols` / `padRightToCols`, used by the CLI renderer
/// (which prints straight to the output writer) and by the header /
/// separator writers below.
pub fn writeCol(out: *std.Io.Writer, content: []const u8, width: usize, right: bool) std.Io.Writer.Error!void {
    const cols = fmt.displayCols(content);
    const pad = if (cols >= width) 0 else width - cols;
    if (right) {
        try out.splatByteAll(' ', pad);
        try out.writeAll(content);
    } else {
        try out.writeAll(content);
        try out.splatByteAll(' ', pad);
    }
}

/// Write the CLI holdings-table column header (Symbol .. Account) sized
/// to `w`. Symbol and Date read left-to-right so their labels sit over
/// the start of the data; the numeric columns are right-justified.
pub fn writeHeader(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
    try out.writeAll("  ");
    try writeCol(out, "Symbol", w.symbol_w, false);
    try out.writeByte(' ');
    try writeCol(out, "Shares", w.shares_w, true);
    try out.writeByte(' ');
    try writeCol(out, "Avg Cost", w.price_w, true);
    try out.writeByte(' ');
    try writeCol(out, "Price", w.price_w, true);
    try out.writeByte(' ');
    try writeCol(out, "Market Value", w.value_w, true);
    try out.writeByte(' ');
    try writeCol(out, "Gain/Loss", w.gainloss_w, true);
    try out.writeByte(' ');
    try writeCol(out, "Weight", w.weight_w, true);
    try out.writeByte(' ');
    try writeCol(out, "Date", w.date_w, false);
    try out.writeAll(" Account\n");
}

/// Write the CLI dashed separator under the header (9 column rules,
/// matching `writeHeader`).
pub fn writeSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
    try out.writeAll("  ");
    try writeRule(out, w.symbol_w);
    try out.writeByte(' ');
    try writeRule(out, w.shares_w);
    try out.writeByte(' ');
    try writeRule(out, w.price_w);
    try out.writeByte(' ');
    try writeRule(out, w.price_w);
    try out.writeByte(' ');
    try writeRule(out, w.value_w);
    try out.writeByte(' ');
    try writeRule(out, w.gainloss_w);
    try out.writeByte(' ');
    try writeRule(out, w.weight_w);
    try out.writeByte(' ');
    try writeRule(out, w.date_w);
    try out.writeByte(' ');
    try writeRule(out, PositionsLayout.account_w);
    try out.writeByte('\n');
}

/// Write the CLI TOTAL-row sum rule, spanning Symbol .. Weight
/// (7 column rules).
pub fn writeTotalSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void {
    try out.writeAll("  ");
    try writeRule(out, w.symbol_w);
    try out.writeByte(' ');
    try writeRule(out, w.shares_w);
    try out.writeByte(' ');
    try writeRule(out, w.price_w);
    try out.writeByte(' ');
    try writeRule(out, w.price_w);
    try out.writeByte(' ');
    try writeRule(out, w.value_w);
    try out.writeByte(' ');
    try writeRule(out, w.gainloss_w);
    try out.writeByte(' ');
    try writeRule(out, w.weight_w);
    try out.writeByte('\n');
}

/// Write a dashed rule `width` columns wide.
fn writeRule(out: *std.Io.Writer, width: usize) std.Io.Writer.Error!void {
    try out.splatByteAll('-', width);
}

// ── Options ───────────────────────────────────────────────────

/// Column layout for the Options section.
/// All format strings are derived from the width constants.
pub const OptionsLayout = struct {
    const cp = std.fmt.comptimePrint;
    pub const prefix = "    ";
    pub const symbol_w = 30;
    pub const qty_w = 6;
    pub const cost_w = 12;
    pub const premium_w = 14;
    pub const account_w = 10;

    pub const premium_col_start: usize = prefix.len + symbol_w + 1 + qty_w + 1 + cost_w + 1;

    pub const header = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:>{d}}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
    pub const header_labels = .{ "Contract", "Qty", "Cost/Ctrct", "Premium", "Account" };

    pub const separator = prefix ++ cp("{{s:->{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:->{d}}}", .{qty_w}) ++ " " ++ cp("{{s:->{d}}}", .{cost_w}) ++ " " ++ cp("{{s:->{d}}}", .{premium_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
    pub const separator_fills = .{ "", "", "", "", "" };

    pub const data_row = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{d:>{d}.0}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
};

/// A styled text span for multi-style row rendering.
pub const StyledSpan = struct {
    text: []const u8,
    style: fmt.StyleIntent,
};

/// A single option row with pre-computed display values.
pub const Option = struct {
    lot: Lot,
    premium: f64,
    received: bool,
    is_expired: bool,
    row_style: fmt.StyleIntent,
    premium_style: fmt.StyleIntent,
    columns: [2]StyledSpan,
    premium_col_start: usize,
};

/// Collection of prepared option rows. Owns all allocated text.
///
/// Rows are sorted by maturity ascending (then symbol), so expired
/// rows form a contiguous prefix: `items[0..expired_count]` are the
/// expired contracts and `items[expired_count..]` are the active ones
/// (including null-maturity lots, which sort last and are never
/// expired). Use `expiredItems()` / `activeItems()` rather than
/// re-deriving the split.
pub const Options = struct {
    items: []const Option,
    /// Count of leading expired rows (the contiguous prefix of
    /// `items` whose `is_expired` is true).
    expired_count: usize,
    allocator: std.mem.Allocator,

    /// Expired option rows (matured strictly before `as_of`).
    pub fn expiredItems(self: Options) []const Option {
        return self.items[0..self.expired_count];
    }

    /// Active option rows (not yet expired, including null-maturity).
    pub fn activeItems(self: Options) []const Option {
        return self.items[self.expired_count..];
    }

    /// Build sorted, filtered, display-ready option rows from raw lots.
    pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options {
        var list: std.ArrayList(Option) = .empty;
        errdefer {
            for (list.items) |opt| allocator.free(opt.columns[0].text);
            list.deinit(allocator);
        }

        var tmp: std.ArrayList(Lot) = .empty;
        defer tmp.deinit(allocator);
        for (lots) |lot| {
            if (lot.security_type != .option) continue;
            if (account_filter) |af| {
                const la = lot.account orelse "";
                if (!std.mem.eql(u8, la, af)) continue;
            }
            try tmp.append(allocator, lot);
        }
        std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);

        var expired_count: usize = 0;
        for (tmp.items) |lot| {
            const qty = lot.shares;
            const cost_per = lot.open_price;
            const premium = @abs(qty) * cost_per * lot.multiplier;
            const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
            if (is_expired) expired_count += 1;
            const received = qty < 0;

            const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
            const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;

            var cost_buf: [24]u8 = undefined;
            var prem_val_buf: [24]u8 = undefined;
            const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
            var prem_buf: [20]u8 = undefined;
            const prem_str = if (received)
                std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
            else
                std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
            const acct = lot.account orelse "";

            const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
                lot.displaySymbol(),
                qty,
                std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
                prem_str,
                acct,
            });

            try list.append(allocator, .{
                .lot = lot,
                .premium = premium,
                .received = received,
                .is_expired = is_expired,
                .row_style = row_style,
                .premium_style = premium_style,
                .columns = .{
                    .{ .text = text, .style = row_style },
                    .{ .text = prem_str, .style = premium_style },
                },
                .premium_col_start = OptionsLayout.premium_col_start,
            });
        }
        return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
    }

    pub fn deinit(self: *Options) void {
        for (self.items) |opt| self.allocator.free(opt.columns[0].text);
        self.allocator.free(self.items);
        self.items = &.{};
    }
};

// ── CDs ───────────────────────────────────────────────────────

/// Column layout for the Certificates of Deposit section.
pub const CDsLayout = struct {
    const cp = std.fmt.comptimePrint;
    pub const prefix = "    ";
    pub const cusip_w = 12;
    pub const face_w = 14;
    pub const rate_w = 7;
    pub const maturity_w = 10;
    pub const desc_w = 40;
    pub const account_w = 10;

    pub const header = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ "  {s}  {s}";
    pub const header_labels = .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account" };

    pub const separator = prefix ++ cp("{{s:->{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:->{d}}}", .{face_w}) ++ " " ++ cp("{{s:->{d}}}", .{rate_w}) ++ " " ++ cp("{{s:->{d}}}", .{maturity_w}) ++ "  " ++ cp("{{s:->{d}}}", .{desc_w}) ++ "  " ++ cp("{{s:->{d}}}", .{account_w});
    pub const separator_fills = .{ "", "", "", "", "", "" };

    pub const data_row = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ "  {s}  {s}";
};

/// A single CD row with pre-computed display values.
pub const CD = struct {
    lot: Lot,
    is_expired: bool,
    row_style: fmt.StyleIntent,
    text: []const u8,
};

/// Collection of prepared CD rows. Owns all allocated text.
///
/// Rows are sorted by maturity ascending, so matured rows form a
/// contiguous prefix: `items[0..expired_count]` are matured and
/// `items[expired_count..]` are still-active (including null-maturity
/// lots, which sort last and are never expired). Use `expiredItems()`
/// / `activeItems()` rather than re-deriving the split.
pub const CDs = struct {
    items: []const CD,
    /// Count of leading matured rows (the contiguous prefix of
    /// `items` whose `is_expired` is true).
    expired_count: usize,
    allocator: std.mem.Allocator,

    /// Matured CD rows (maturity strictly before `as_of`).
    pub fn expiredItems(self: CDs) []const CD {
        return self.items[0..self.expired_count];
    }

    /// Active CD rows (not yet matured, including null-maturity).
    pub fn activeItems(self: CDs) []const CD {
        return self.items[self.expired_count..];
    }

    /// Build sorted, filtered, display-ready CD rows from raw lots.
    pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs {
        var list: std.ArrayList(CD) = .empty;
        errdefer {
            for (list.items) |cd| allocator.free(cd.text);
            list.deinit(allocator);
        }

        var tmp: std.ArrayList(Lot) = .empty;
        defer tmp.deinit(allocator);
        for (lots) |lot| {
            if (lot.security_type != .cd) continue;
            if (account_filter) |af| {
                const la = lot.account orelse "";
                if (!std.mem.eql(u8, la, af)) continue;
            }
            try tmp.append(allocator, lot);
        }
        std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);

        var expired_count: usize = 0;
        for (tmp.items) |lot| {
            const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
            if (is_expired) expired_count += 1;
            const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;

            var face_buf: [24]u8 = undefined;
            var mat_buf: [10]u8 = undefined;
            const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
            var rate_buf: [10]u8 = undefined;
            const rate_str: []const u8 = if (lot.rate) |r|
                std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
            else
                "--";
            const note_str: []const u8 = lot.note orelse "";
            const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
            const acct = lot.account orelse "";

            const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
                lot.displaySymbol(),
                std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
                rate_str,
                mat_str,
                note_display,
                acct,
            });

            try list.append(allocator, .{
                .lot = lot,
                .is_expired = is_expired,
                .row_style = row_style,
                .text = text,
            });
        }
        return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
    }

    pub fn deinit(self: *CDs) void {
        for (self.items) |cd| self.allocator.free(cd.text);
        self.allocator.free(self.items);
        self.items = &.{};
    }
};

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

const testing = std.testing;

test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        // active (future maturity)
        .{ .symbol = "AAA 2024-12-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) },
        // expired (past maturity)
        .{ .symbol = "BBB 2024-01-01 C50", .shares = 1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 1, 1) },
        // null-maturity option: never expired, sorts last
        .{ .symbol = "CCC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option },
        // non-option: ignored
        .{ .symbol = "ZZZ", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .stock },
    };
    var opts = try Options.init(as_of, testing.allocator, &lots, null);
    defer opts.deinit();

    try testing.expectEqual(@as(usize, 3), opts.items.len);
    try testing.expectEqual(@as(usize, 1), opts.expired_count);
    try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
    try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
    try testing.expect(opts.expiredItems()[0].is_expired);
    try testing.expectEqualStrings("BBB 2024-01-01 C50", opts.expiredItems()[0].lot.symbol);
    for (opts.activeItems()) |a| try testing.expect(!a.is_expired);
}

test "Options.init: no expired items yields empty expired slice" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "AAA", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) },
    };
    var opts = try Options.init(as_of, testing.allocator, &lots, null);
    defer opts.deinit();
    try testing.expectEqual(@as(usize, 0), opts.expired_count);
    try testing.expectEqual(@as(usize, 0), opts.expiredItems().len);
    try testing.expectEqual(@as(usize, 1), opts.activeItems().len);
}

test "CDs.init: matured rows form a prefix; active/expired slices split correctly" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .rate = 4.5 },
        .{ .symbol = "CD-MATURED", .shares = 5000, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1), .rate = 3.0 },
    };
    var cds = try CDs.init(as_of, testing.allocator, &lots, null);
    defer cds.deinit();

    try testing.expectEqual(@as(usize, 2), cds.items.len);
    try testing.expectEqual(@as(usize, 1), cds.expired_count);
    try testing.expectEqual(@as(usize, 1), cds.expiredItems().len);
    try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
    try testing.expect(cds.expiredItems()[0].is_expired);
    try testing.expect(!cds.activeItems()[0].is_expired);
    try testing.expectEqualStrings("CD-MATURED", cds.expiredItems()[0].lot.symbol);
    try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
}

test "CDs.init: all matured yields empty active slice" {
    const as_of = Date.fromYmd(2024, 6, 1);
    const lots = [_]Lot{
        .{ .symbol = "CD-OLD-1", .shares = 5000, .open_date = Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2023, 1, 1) },
        .{ .symbol = "CD-OLD-2", .shares = 7000, .open_date = Date.fromYmd(2022, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1) },
    };
    var cds = try CDs.init(as_of, testing.allocator, &lots, null);
    defer cds.deinit();
    try testing.expectEqual(@as(usize, 2), cds.expired_count);
    try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
    try testing.expectEqual(@as(usize, 0), cds.activeItems().len);
}

// ── computeWidths ─────────────────────────────────────────────

/// Build a minimal Allocation for width tests. `cost_basis` is derived
/// so `unrealized_gain_loss` is consistent (not that computeWidths
/// reads cost_basis, but it keeps the fixture honest).
fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, market_value: f64, gl: f64) Allocation {
    return .{
        .symbol = symbol,
        .display_symbol = symbol,
        .shares = shares,
        .avg_cost = avg_cost,
        .current_price = current_price,
        .market_value = market_value,
        .cost_basis = market_value - gl,
        .weight = 1.0,
        .unrealized_gain_loss = gl,
        .unrealized_return = 0,
    };
}

test "computeWidths: empty portfolio sits at the header-label floors" {
    const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
    try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
    try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
    try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
    try testing.expectEqual(@as(usize, PositionsLayout.min_value_w), w.value_w);
    try testing.expectEqual(@as(usize, PositionsLayout.min_gainloss_w), w.gainloss_w);
    // Fixed columns are mirrored in unchanged.
    try testing.expectEqual(@as(usize, PositionsLayout.weight_w), w.weight_w);
    try testing.expectEqual(@as(usize, PositionsLayout.date_w), w.date_w);
}

test "computeWidths: a small portfolio stays at the floors (tightens, no waste)" {
    // IBM(3) / 10.0(4) / $150.00(7) / $155.00(7) / $1,550.00(9) / +$50.00(7)
    // are all narrower than their header labels, so every column stays
    // at its minimum. This is the "don't waste space" half of
    // fit-to-content.
    const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
    const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
    try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
    try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
    try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
    try testing.expectEqual(@as(usize, 12), w.value_w); // "Market Value"
    try testing.expectEqual(@as(usize, 9), w.gainloss_w); // "Gain/Loss"
}

test "computeWidths: large crypto-scale values grow each column" {
    // DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
    // / +$5,000,000.00(14, sign + $5,000,000.00).
    const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
    const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
    try testing.expectEqual(@as(usize, 8), w.symbol_w);
    try testing.expectEqual(@as(usize, 7), w.shares_w);
    try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
    try testing.expectEqual(@as(usize, 15), w.value_w);
    try testing.expectEqual(@as(usize, 14), w.gainloss_w);
}

test "computeWidths: a single lot's gain/loss can exceed the netted position" {
    // Position nets to $0 gain/loss, but two opposite-sign lots each
    // swing $9,900 ("+$9,900.00" = 10 cols). Without scanning lots the
    // Gain/Loss column would size to the $0 net (the 9-col floor) and a
    // lot row would overflow.
    const allocs = [_]Allocation{mkAlloc("ABC", 200, 100, 100, 20000, 0)};
    const lots = [_]Lot{
        .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0 },
        .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
    };
    // Allocations only: stays at the 9-col floor.
    const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
    try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
    // With lots: grows to fit the per-lot swing.
    const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
    try testing.expectEqual(@as(usize, 10), w.gainloss_w);
}

test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tolerated" {
    const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
    const lots = [_]Lot{
        // Non-stock lot is skipped: its huge share count must NOT widen
        // the Shares column.
        .{ .symbol = "CASHX", .shares = 999999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash },
        // Closed stock lot: market value / gain-loss use close_price
        // (1234), not the allocation's current price (60). gl =
        // 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
        .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
        // Stock lot whose symbol has no matching allocation and no
        // close_price: currentPriceFor falls back to 0 (exercises the
        // not-found path), contributing nothing.
        .{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
    };
    const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
    // Non-stock lot skipped -> Shares stays at the 6-col floor (the
    // 999999.0 = 8-col share count was ignored).
    try testing.expectEqual(@as(usize, 6), w.shares_w);
    // close_price honored -> Gain/Loss grows to the closed lot's 12-col
    // swing. (Had currentPriceFor been used instead, the swing would be
    // 100*(60-10) = 5000 -> 10 cols, so 12 proves close_price won.)
    try testing.expectEqual(@as(usize, 12), w.gainloss_w);
}

test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
    var wp = std.StringHashMap(f64).init(testing.allocator);
    defer wp.deinit();
    try wp.put("VERYLONGSYM", 1234.56); // "$1,234.56" = 9 cols
    // "NOPRICE" has no map entry, so it widens Symbol but not Price
    // (exercises the price-absent branch).
    const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
    const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
    try testing.expectEqual(@as(usize, 11), w.symbol_w);
    try testing.expectEqual(@as(usize, 9), w.price_w);
}

test "writeHeader / writeSeparator render the same column count as the widths" {
    var buf: [256]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    const widths: PositionsWidths = .{ .symbol_w = 8, .shares_w = 7, .price_w = 10, .value_w = 15, .gainloss_w = 14 };
    try writeHeader(&w, widths);
    const header = w.buffered();
    try testing.expect(std.mem.indexOf(u8, header, "Symbol") != null);
    try testing.expect(std.mem.indexOf(u8, header, "Market Value") != null);
    try testing.expect(std.mem.indexOf(u8, header, "Account") != null);
    try testing.expect(std.mem.endsWith(u8, header, "\n"));

    var sbuf: [256]u8 = undefined;
    var sw: std.Io.Writer = .fixed(&sbuf);
    try writeSeparator(&sw, widths);
    const sep = sw.buffered();
    // The separator's per-column rules match the header columns. Its one
    // intentional difference is the trailing Account rule, which is a
    // fixed decorative width (account_w) rather than the natural-width
    // "Account" label, so the separator is exactly that much longer.
    try testing.expectEqual(
        fmt.displayCols(header[0 .. header.len - 1]) - "Account".len + PositionsLayout.account_w,
        fmt.displayCols(sep[0 .. sep.len - 1]),
    );
}

test "computeWidths: lot columns size to effective (split-adjusted) shares" {
    // A pre-split lot enriched with a 10:1 factor renders 1000 shares,
    // so the shares column must be sized for the effective count, not
    // the raw 100.
    var lots = [_]Lot{
        .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40, .split_factor = 10.0 },
    };
    const no_allocs: []const Allocation = &.{};
    const no_watch: []const []const u8 = &.{};
    const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
    try testing.expectEqual(sharesCols(1000.0), w.shares_w);
    try testing.expect(w.shares_w >= sharesCols(100.0));
}
