//! `src/views/compare.zig` — view model for the portfolio comparison UX.
//!
//! Renderer-agnostic display data: no ANSI, no writer, no vaxis. Sits
//! alongside `views/portfolio_sections.zig` and `views/history.zig`
//! in the views layer. CLI and TUI renderers both consume the
//! `CompareView` produced here:
//!
//!   - CLI renderer: `src/commands/compare.zig` (ANSI writer)
//!   - TUI renderer: `src/tui/history_tab.zig` (vaxis-styled lines)
//!
//! ## Semantics
//!
//! Two snapshots — "then" and "now" — each described as a map of
//! `symbol -> (shares, price)` plus a liquid-value total. "Now" may be
//! either another historical snapshot or the live portfolio; the view
//! doesn't care which.
//!
//! ### Liquid totals
//!
//! Raw delta: `now - then`. This **includes contributions and withdrawals**.
//! The per-symbol section below is the pure investment signal — total-level
//! returns are deliberately not adjusted for flows, because reconstructing
//! contribution history is out of scope.
//!
//! ### Per-symbol price change
//!
//! Only symbols held on **both** dates appear. Added/removed positions are
//! counted but not rendered — that matches the "I don't care about added/
//! removed" constraint and keeps the output scannable.
//!
//! Two per-symbol numbers:
//!   - `pct_change` = `price_now / price_then - 1` (price-only, share-count
//!     changes between the two dates don't affect it)
//!   - `dollar_change` = `min(shares_then, shares_now) * (price_now - price_then)`
//!     — the "held throughout" dollar impact. Uses the share floor to
//!     isolate continuously-held exposure; shares added between the dates
//!     don't contribute (matching the "don't count adds" intent),
//!     shares sold don't either.
//!
//! Sorted by `pct_change` descending — biggest winners first.
//!
//! ## Contract
//!
//! No IO, no fetching, no rendering. Callers are responsible for
//! loading snapshots, aggregating lot rows by symbol, and producing
//! the two `HoldingMap`s (see `src/compare.zig`). This module does
//! the math and returns a sorted, styled (via `StyleIntent`) view
//! that either renderer can consume.

const std = @import("std");
const fmt = @import("../format.zig");
const Date = @import("../Date.zig");
const Money = @import("../Money.zig");
const timeline = @import("../analytics/timeline.zig");
const view_hist = @import("history.zig");

pub const StyleIntent = fmt.StyleIntent;

// ── Data types ───────────────────────────────────────────────

/// A single per-symbol comparison row, pre-computed.
///
/// The `symbol` string is borrowed from the caller's `HoldingMap` — the
/// caller must keep that map (and its backing buffers) alive as long as
/// the view.
pub const SymbolChange = struct {
    symbol: []const u8,
    price_then: f64,
    price_now: f64,
    /// `min(shares_then, shares_now)` — the continuously-held floor.
    /// Drives `dollar_change`.
    shares_held_throughout: f64,
    /// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by
    /// 100 at format time via `fmtSignedPercentBuf` or similar.
    pct_change: f64,
    /// `shares_held_throughout * (price_now - price_then)`. Signed.
    dollar_change: f64,
    /// `.positive` when pct_change > 0, `.negative` when < 0,
    /// `.muted` when exactly zero.
    style: StyleIntent,
};

/// The liquid-total row for the totals section. Raw delta (includes flows).
pub const TotalsRow = struct {
    then: f64,
    now: f64,
    /// `now - then`.
    delta: f64,
    /// Ratio. `0.05` means +5%. Zero when `then` is zero (avoids NaN).
    pct: f64,
    /// `.positive`/`.negative`/`.muted` by `delta` sign.
    style: StyleIntent,
};

/// Optional attribution breakdown of the liquid delta into
/// contributions (money in) vs investment gains (market movement).
///
/// Populated in both single-date ("vs current") and two-date modes
/// when the portfolio is git-tracked and both endpoints resolve to
/// commits via `git.resolveCommitRange`. Silently null when the git
/// lookup fails (missing repo, untracked file, no commits in range);
/// the compare view falls back to just the totals + per-symbol table.
///
/// Math: `delta = contributions + gains`, so `gains = delta - contributions`.
/// Signs are preserved: negative contributions (net withdrawal) and
/// negative gains (market loss) both appear.
pub const Attribution = struct {
    /// Sum of new-money contributions plus DRIP reinvestments (what
    /// `zfin contributions` reports as "money in").
    contributions: f64,
    /// `TotalsRow.delta - contributions`. The residual — what the
    /// market actually did.
    gains: f64,
};

/// Complete compare view. `symbols` is caller-owned; call `deinit()`.
pub const CompareView = struct {
    then_date: Date,
    now_date: Date,
    /// `now_date.days - then_date.days`. Can be zero (same-day compare
    /// is a no-op but won't error) or negative if the caller didn't
    /// normalize order, but the CLI always swaps so it's ≥ 0 in
    /// practice.
    days_between: i32,
    /// True when the "now" side is the live portfolio (not another
    /// snapshot). Renderers can use this to distinguish "compared to
    /// today" from "compared to another snapshot date".
    now_is_live: bool,
    liquid: TotalsRow,
    /// Sorted by `pct_change` descending. Owned by the view.
    symbols: []SymbolChange,
    /// Count of held-throughout symbols — always `== symbols.len`;
    /// surfaced here for convenience in rendering the "(N held
    /// throughout)" subtitle.
    held_count: usize,
    /// Symbols present in "now" but not "then" — position opened
    /// between the two dates. Never rendered as rows; shown as a count.
    added_count: usize,
    /// Symbols present in "then" but not "now" — position closed
    /// between the two dates. Never rendered as rows; shown as a count.
    removed_count: usize,
    /// Number of held-throughout symbols with `pct_change > flat_threshold`.
    /// Intended for the per-symbol summary footer ("21 gainers, 5 losers").
    gainer_count: usize = 0,
    /// Number of held-throughout symbols with `pct_change < -flat_threshold`.
    loser_count: usize = 0,
    /// Number of held-throughout symbols with `|pct_change| <= flat_threshold`.
    /// `gainer_count + loser_count + flat_count == held_count`.
    flat_count: usize = 0,
    /// Optional contributions-vs-gains breakdown of `liquid.delta`.
    /// Populated by the CLI from `computeAttributionSpec` when a git repo
    /// is available; always null in unit-tested / TUI flows.
    attribution: ?Attribution = null,

    /// Optional human-facing labels for each side. When set, the
    /// renderer uses these instead of the bare `YYYY-MM-DD` for the
    /// header — useful for bucketed selections where the user picked
    /// (e.g.) "Q1 2025" and we want to render
    /// "Q1 2025 (ended 2025-03-28)" rather than "2025-03-28" alone.
    /// Null falls back to ISO-date rendering.
    then_label: ?[]const u8 = null,
    now_label: ?[]const u8 = null,

    pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
        allocator.free(self.symbols);
        if (self.then_label) |l| allocator.free(l);
        if (self.now_label) |l| allocator.free(l);
    }
};

/// Build a human-facing label for a History-tab row selected as
/// the "then" or "now" side of a compare view. Returns an
/// allocator-owned string like `"Q1 2025 (ended 2025-03-28)"`,
/// or null when the caller should fall back to plain ISO-date
/// rendering. Null is returned for:
///   - Live rows (the "now" side often points at the in-progress
///     bucket; caller renders this as `"today"` itself).
///   - Daily rows or rows without a tier (the ISO date is already
///     the right label — annotating `"2025-03-28 (ended 2025-03-28)"`
///     would be useless duplication).
///
/// The label is built by composing `timeline.formatBucketLabel`
/// with an ` (ended YYYY-MM-DD)` suffix, so this function shares
/// all tier-formatting logic with the cascading-history table.
///
/// `tier`/`bucket_start` are optional to match the upstream
/// `TableRow`/CLI bucket-row shapes, where rows without a bucket
/// origin (live rows, plain daily rows) carry null. When
/// `bucket_start` is null but a tier is present the function
/// returns the ISO date alone (defensive — shouldn't happen for
/// non-daily rows, but keeps the renderer honest if it does).
pub fn buildBucketLabel(
    allocator: std.mem.Allocator,
    tier: ?timeline.Tier,
    bucket_start: ?Date,
    date: Date,
    is_live: bool,
) !?[]const u8 {
    if (is_live) return null;
    const t = tier orelse return null;
    if (t == .daily) return null;

    var iso_buf: [10]u8 = undefined;
    const iso = std.fmt.bufPrint(&iso_buf, "{f}", .{date}) catch "????-??-??";

    // No bucket origin → return ISO alone (caller may have wanted
    // a label for some surface-specific reason; better than null
    // here because we already committed to "non-daily" above).
    const start = bucket_start orelse return try allocator.dupe(u8, iso);

    var prefix_buf: [32]u8 = undefined;
    const prefix = timeline.formatBucketLabel(&prefix_buf, t, start);
    return try std.fmt.allocPrint(allocator, "{s} (ended {s})", .{ prefix, iso });
}

/// Threshold under which a pct_change is considered flat for the
/// gainer/loser summary footer. `0.0001 == 0.01%`. Chosen so penny-
/// level rounding noise on high-priced positions (e.g. a $500 stock
/// moving one cent is 0.002%) stays out of the "gainers" bucket while
/// anything visibly colored as positive/negative in the per-symbol
/// table crosses the threshold.
pub const flat_threshold: f64 = 0.0001;

/// One entry in a holdings snapshot — total shares held of `symbol` and
/// the per-share price at that moment. Caller-populated; the view model
/// doesn't know or care where the numbers came from.
pub const Holding = struct {
    shares: f64,
    price: f64,
};

/// Symbol → Holding. String keys are caller-owned; keep them alive as
/// long as the resulting `CompareView`.
pub const HoldingMap = std.StringHashMap(Holding);

// ── Pure builders ────────────────────────────────────────────

/// Compute a single per-symbol change from the raw inputs.
///
/// The pct-change denominator is `price_then`. If `price_then` is zero
/// (shouldn't happen for stocks but guards against bad data), the
/// pct_change is reported as 0 rather than a NaN/Inf leaking into the
/// sort comparator.
pub fn buildSymbolChange(
    symbol: []const u8,
    shares_then: f64,
    price_then: f64,
    shares_now: f64,
    price_now: f64,
) SymbolChange {
    const held = @min(shares_then, shares_now);
    const pct = if (price_then != 0) (price_now / price_then - 1.0) else 0.0;
    const dollar = held * (price_now - price_then);
    const style: StyleIntent = if (pct > 0) .positive else if (pct < 0) .negative else .muted;
    return .{
        .symbol = symbol,
        .price_then = price_then,
        .price_now = price_now,
        .shares_held_throughout = held,
        .pct_change = pct,
        .dollar_change = dollar,
        .style = style,
    };
}

/// Compute the liquid totals row. Safe when `then == 0` (pct → 0 rather
/// than NaN).
pub fn buildTotalsRow(then: f64, now: f64) TotalsRow {
    const delta = now - then;
    const pct = if (then != 0) delta / then else 0.0;
    const style: StyleIntent = if (delta > 0) .positive else if (delta < 0) .negative else .muted;
    return .{
        .then = then,
        .now = now,
        .delta = delta,
        .pct = pct,
        .style = style,
    };
}

/// Primary view builder. Intersects `then_map` with `now_map`, computes
/// per-symbol changes for held-throughout symbols, counts added/removed,
/// and sorts descending by `pct_change`.
///
/// Symbol strings in the returned view borrow from `then_map`'s keys
/// (since we iterate `then_map` to build the intersection) — the caller
/// must keep `then_map` alive at least as long as the view. Alternatively,
/// if the view needs to outlive both maps, the caller can dupe the
/// strings before passing them in.
pub fn buildCompareView(
    allocator: std.mem.Allocator,
    then_date: Date,
    now_date: Date,
    now_is_live: bool,
    liquid_then: f64,
    liquid_now: f64,
    then_map: *const HoldingMap,
    now_map: *const HoldingMap,
) !CompareView {
    var changes: std.ArrayList(SymbolChange) = .empty;
    errdefer changes.deinit(allocator);

    var added: usize = 0;
    var removed: usize = 0;

    // Count added: in now, not in then.
    var now_it = now_map.iterator();
    while (now_it.next()) |e| {
        if (!then_map.contains(e.key_ptr.*)) added += 1;
    }

    // Walk "then" to build the intersection and count removed.
    var then_it = then_map.iterator();
    while (then_it.next()) |e| {
        const sym = e.key_ptr.*;
        const then_h = e.value_ptr.*;
        if (now_map.get(sym)) |now_h| {
            try changes.append(allocator, buildSymbolChange(
                sym,
                then_h.shares,
                then_h.price,
                now_h.shares,
                now_h.price,
            ));
        } else {
            removed += 1;
        }
    }

    // Sort by pct_change descending. Stable is fine; stability isn't
    // semantically relevant here but is cheaper in the not-all-unique case.
    std.mem.sort(SymbolChange, changes.items, {}, struct {
        fn lt(_: void, a: SymbolChange, b: SymbolChange) bool {
            return a.pct_change > b.pct_change;
        }
    }.lt);

    // Bucket held-throughout rows into gainers / losers / flat using
    // `flat_threshold` so that cent-rounding noise on a high-priced
    // position doesn't get counted as a win or a loss. Computed after
    // the sort purely for locality — buckets are independent of order.
    var gainers: usize = 0;
    var losers: usize = 0;
    var flats: usize = 0;
    for (changes.items) |c| {
        if (c.pct_change > flat_threshold) {
            gainers += 1;
        } else if (c.pct_change < -flat_threshold) {
            losers += 1;
        } else {
            flats += 1;
        }
    }

    const items = try changes.toOwnedSlice(allocator);
    return .{
        .then_date = then_date,
        .now_date = now_date,
        .days_between = now_date.days - then_date.days,
        .now_is_live = now_is_live,
        .liquid = buildTotalsRow(liquid_then, liquid_now),
        .symbols = items,
        .held_count = items.len,
        .added_count = added,
        .removed_count = removed,
        .gainer_count = gainers,
        .loser_count = losers,
        .flat_count = flats,
    };
}

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

const testing = std.testing;

test "buildBucketLabel: live and daily rows return null" {
    // Live row.
    const got_live = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 11), true);
    try testing.expectEqual(@as(?[]const u8, null), got_live);

    // Daily-tier row.
    const got_daily = try buildBucketLabel(testing.allocator, .daily, null, Date.fromYmd(2026, 5, 8), false);
    try testing.expectEqual(@as(?[]const u8, null), got_daily);

    // No tier at all.
    const got_no_tier = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 8), false);
    try testing.expectEqual(@as(?[]const u8, null), got_no_tier);
}

test "buildBucketLabel: quarterly row produces 'Qn YYYY (ended ...)' label" {
    const lbl = (try buildBucketLabel(
        testing.allocator,
        .quarterly,
        Date.fromYmd(2025, 1, 1),
        Date.fromYmd(2025, 3, 28),
        false,
    )).?;
    defer testing.allocator.free(lbl);
    try testing.expectEqualStrings("Q1 2025 (ended 2025-03-28)", lbl);
}

test "buildBucketLabel: yearly row produces 'YYYY (ended ...)' label" {
    const lbl = (try buildBucketLabel(
        testing.allocator,
        .yearly,
        Date.fromYmd(2024, 1, 1),
        Date.fromYmd(2024, 12, 31),
        false,
    )).?;
    defer testing.allocator.free(lbl);
    try testing.expectEqualStrings("2024 (ended 2024-12-31)", lbl);
}

test "buildBucketLabel: monthly row produces 'Mmm YYYY (ended ...)' label" {
    const lbl = (try buildBucketLabel(
        testing.allocator,
        .monthly,
        Date.fromYmd(2026, 1, 1),
        Date.fromYmd(2026, 1, 30),
        false,
    )).?;
    defer testing.allocator.free(lbl);
    try testing.expectEqualStrings("Jan 2026 (ended 2026-01-30)", lbl);
}

test "buildBucketLabel: weekly row produces 'W of YYYY-MM-DD (ended ...)' label" {
    const lbl = (try buildBucketLabel(
        testing.allocator,
        .weekly,
        Date.fromYmd(2026, 4, 20),
        Date.fromYmd(2026, 4, 22),
        false,
    )).?;
    defer testing.allocator.free(lbl);
    try testing.expectEqualStrings("W of 2026-04-20 (ended 2026-04-22)", lbl);
}

test "buildBucketLabel: missing bucket_start on non-daily tier falls back to ISO" {
    const lbl = (try buildBucketLabel(
        testing.allocator,
        .quarterly,
        null,
        Date.fromYmd(2025, 3, 28),
        false,
    )).?;
    defer testing.allocator.free(lbl);
    try testing.expectEqualStrings("2025-03-28", lbl);
}

test "buildSymbolChange: positive price move, shares stable" {
    const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
    try testing.expectEqualStrings("AAPL", c.symbol);
    try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
    try testing.expectApproxEqAbs(@as(f64, 1500.0), c.dollar_change, 1e-9);
    try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
    try testing.expectEqual(StyleIntent.positive, c.style);
}

test "buildSymbolChange: negative move" {
    const c = buildSymbolChange("NFLX", 10, 500.0, 10, 400.0);
    try testing.expectApproxEqAbs(@as(f64, -0.20), c.pct_change, 1e-9);
    try testing.expectApproxEqAbs(@as(f64, -1000.0), c.dollar_change, 1e-9);
    try testing.expectEqual(StyleIntent.negative, c.style);
}

test "buildSymbolChange: zero price move → muted style, zero dollar" {
    const c = buildSymbolChange("VTI", 50, 240.0, 50, 240.0);
    try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9);
    try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9);
    try testing.expectEqual(StyleIntent.muted, c.style);
}

test "buildSymbolChange: shares_held is min (added shares between dates)" {
    // Started with 100, added 50, now at 150. Held-throughout floor = 100.
    const c = buildSymbolChange("MSFT", 100, 400.0, 150, 420.0);
    try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
    // dollar = 100 * (420-400) = 2000, not 150 * 20 = 3000
    try testing.expectApproxEqAbs(@as(f64, 2000.0), c.dollar_change, 1e-9);
}

test "buildSymbolChange: shares_held is min (sold shares between dates)" {
    // Started with 200, sold down to 50. Held-throughout floor = 50.
    const c = buildSymbolChange("GOOG", 200, 160.0, 50, 180.0);
    try testing.expectEqual(@as(f64, 50), c.shares_held_throughout);
    // dollar = 50 * (180-160) = 1000
    try testing.expectApproxEqAbs(@as(f64, 1000.0), c.dollar_change, 1e-9);
}

test "buildSymbolChange: zero price_then doesn't NaN" {
    const c = buildSymbolChange("BAD", 10, 0.0, 10, 50.0);
    try testing.expectEqual(@as(f64, 0.0), c.pct_change);
    try testing.expectEqual(StyleIntent.muted, c.style);
    // dollar_change is still 10 * 50 = 500 — that's the true held-throughout
    // price delta even though % is undefined.
    try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
}

test "buildTotalsRow: positive delta" {
    const t = buildTotalsRow(1_000_000.0, 1_050_000.0);
    try testing.expectApproxEqAbs(@as(f64, 50_000.0), t.delta, 1e-6);
    try testing.expectApproxEqAbs(@as(f64, 0.05), t.pct, 1e-9);
    try testing.expectEqual(StyleIntent.positive, t.style);
}

test "buildTotalsRow: negative delta" {
    const t = buildTotalsRow(1_000_000.0, 950_000.0);
    try testing.expectApproxEqAbs(@as(f64, -50_000.0), t.delta, 1e-6);
    try testing.expectApproxEqAbs(@as(f64, -0.05), t.pct, 1e-9);
    try testing.expectEqual(StyleIntent.negative, t.style);
}

test "buildTotalsRow: zero delta" {
    const t = buildTotalsRow(100.0, 100.0);
    try testing.expectEqual(@as(f64, 0.0), t.delta);
    try testing.expectEqual(StyleIntent.muted, t.style);
}

test "buildTotalsRow: zero then doesn't NaN" {
    const t = buildTotalsRow(0.0, 100.0);
    try testing.expectEqual(@as(f64, 0.0), t.pct);
    try testing.expectEqual(StyleIntent.positive, t.style); // delta > 0 so positive
}

test "buildCompareView: intersection with added and removed" {
    var then_map: HoldingMap = .init(testing.allocator);
    defer then_map.deinit();
    var now_map: HoldingMap = .init(testing.allocator);
    defer now_map.deinit();

    // Held in both (will appear)
    try then_map.put("AAPL", .{ .shares = 100, .price = 150.0 });
    try now_map.put("AAPL", .{ .shares = 100, .price = 165.0 });
    try then_map.put("MSFT", .{ .shares = 50, .price = 400.0 });
    try now_map.put("MSFT", .{ .shares = 50, .price = 395.0 });
    // Removed (in then, not in now)
    try then_map.put("NFLX", .{ .shares = 10, .price = 500.0 });
    // Added (in now, not in then)
    try now_map.put("TSLA", .{ .shares = 20, .price = 250.0 });
    try now_map.put("NVDA", .{ .shares = 15, .price = 140.0 });

    var view = try buildCompareView(
        testing.allocator,
        Date.fromYmd(2026, 4, 20),
        Date.fromYmd(2026, 4, 30),
        true, // live
        1_000_000.0,
        1_050_000.0,
        &then_map,
        &now_map,
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 2), view.held_count);
    try testing.expectEqual(@as(usize, 2), view.added_count);
    try testing.expectEqual(@as(usize, 1), view.removed_count);
    try testing.expectEqual(@as(i32, 10), view.days_between);
    try testing.expectEqual(true, view.now_is_live);

    // Sort order: AAPL (+10%) before MSFT (-1.25%)
    try testing.expectEqualStrings("AAPL", view.symbols[0].symbol);
    try testing.expectEqualStrings("MSFT", view.symbols[1].symbol);
    try testing.expect(view.symbols[0].pct_change > view.symbols[1].pct_change);

    // Totals row
    try testing.expectApproxEqAbs(@as(f64, 0.05), view.liquid.pct, 1e-9);
    try testing.expectEqual(StyleIntent.positive, view.liquid.style);
}

test "buildCompareView: empty intersection" {
    var then_map: HoldingMap = .init(testing.allocator);
    defer then_map.deinit();
    var now_map: HoldingMap = .init(testing.allocator);
    defer now_map.deinit();

    try then_map.put("OLD", .{ .shares = 10, .price = 100.0 });
    try now_map.put("NEW", .{ .shares = 5, .price = 200.0 });

    var view = try buildCompareView(
        testing.allocator,
        Date.fromYmd(2026, 1, 1),
        Date.fromYmd(2026, 2, 1),
        false,
        1000.0,
        2000.0,
        &then_map,
        &now_map,
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 0), view.held_count);
    try testing.expectEqual(@as(usize, 1), view.added_count);
    try testing.expectEqual(@as(usize, 1), view.removed_count);
    try testing.expectEqual(false, view.now_is_live);
}

test "buildCompareView: both empty" {
    var then_map: HoldingMap = .init(testing.allocator);
    defer then_map.deinit();
    var now_map: HoldingMap = .init(testing.allocator);
    defer now_map.deinit();

    var view = try buildCompareView(
        testing.allocator,
        Date.fromYmd(2026, 1, 1),
        Date.fromYmd(2026, 1, 1),
        true,
        0.0,
        0.0,
        &then_map,
        &now_map,
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 0), view.held_count);
    try testing.expectEqual(@as(usize, 0), view.added_count);
    try testing.expectEqual(@as(usize, 0), view.removed_count);
    try testing.expectEqual(@as(i32, 0), view.days_between);
    try testing.expectEqual(StyleIntent.muted, view.liquid.style);
}

test "buildCompareView: sort strictly descending across many symbols" {
    var then_map: HoldingMap = .init(testing.allocator);
    defer then_map.deinit();
    var now_map: HoldingMap = .init(testing.allocator);
    defer now_map.deinit();

    // Seed 5 held-throughout symbols with distinct returns
    try then_map.put("A", .{ .shares = 10, .price = 100.0 });
    try now_map.put("A", .{ .shares = 10, .price = 110.0 }); // +10%
    try then_map.put("B", .{ .shares = 10, .price = 100.0 });
    try now_map.put("B", .{ .shares = 10, .price = 80.0 }); // -20%
    try then_map.put("C", .{ .shares = 10, .price = 100.0 });
    try now_map.put("C", .{ .shares = 10, .price = 130.0 }); // +30%
    try then_map.put("D", .{ .shares = 10, .price = 100.0 });
    try now_map.put("D", .{ .shares = 10, .price = 95.0 }); // -5%
    try then_map.put("E", .{ .shares = 10, .price = 100.0 });
    try now_map.put("E", .{ .shares = 10, .price = 105.0 }); // +5%

    var view = try buildCompareView(
        testing.allocator,
        Date.fromYmd(2026, 1, 1),
        Date.fromYmd(2026, 2, 1),
        false,
        5000.0,
        5200.0,
        &then_map,
        &now_map,
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 5), view.held_count);
    // Expected order by pct desc: C (+30), A (+10), E (+5), D (-5), B (-20)
    try testing.expectEqualStrings("C", view.symbols[0].symbol);
    try testing.expectEqualStrings("A", view.symbols[1].symbol);
    try testing.expectEqualStrings("E", view.symbols[2].symbol);
    try testing.expectEqualStrings("D", view.symbols[3].symbol);
    try testing.expectEqualStrings("B", view.symbols[4].symbol);

    // Gainer/loser buckets: C/A/E > flat_threshold, D/B < -flat_threshold
    try testing.expectEqual(@as(usize, 3), view.gainer_count);
    try testing.expectEqual(@as(usize, 2), view.loser_count);
    try testing.expectEqual(@as(usize, 0), view.flat_count);
}

test "buildCompareView: gainer/loser/flat buckets with near-zero moves" {
    var then_map: HoldingMap = .init(testing.allocator);
    defer then_map.deinit();
    var now_map: HoldingMap = .init(testing.allocator);
    defer now_map.deinit();

    // Clear gainer
    try then_map.put("UP", .{ .shares = 1, .price = 100.0 });
    try now_map.put("UP", .{ .shares = 1, .price = 101.0 }); // +1%
    // Clear loser
    try then_map.put("DN", .{ .shares = 1, .price = 100.0 });
    try now_map.put("DN", .{ .shares = 1, .price = 98.0 }); // -2%
    // Exactly flat
    try then_map.put("FLAT", .{ .shares = 1, .price = 100.0 });
    try now_map.put("FLAT", .{ .shares = 1, .price = 100.0 }); // 0%
    // Rounding-noise positive (below flat_threshold of 0.01%)
    try then_map.put("NOISE", .{ .shares = 1, .price = 1000.0 });
    try now_map.put("NOISE", .{ .shares = 1, .price = 1000.05 }); // +0.005%

    var view = try buildCompareView(
        testing.allocator,
        Date.fromYmd(2026, 1, 1),
        Date.fromYmd(2026, 2, 1),
        false,
        4000.0,
        4001.0,
        &then_map,
        &now_map,
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 4), view.held_count);
    try testing.expectEqual(@as(usize, 1), view.gainer_count);
    try testing.expectEqual(@as(usize, 1), view.loser_count);
    try testing.expectEqual(@as(usize, 2), view.flat_count);
    // Sanity: buckets sum to held_count.
    try testing.expectEqual(view.held_count, view.gainer_count + view.loser_count + view.flat_count);
}

test "buildCompareView: zero held-throughout yields zero counts for all three buckets" {
    var then_map: HoldingMap = .init(testing.allocator);
    defer then_map.deinit();
    var now_map: HoldingMap = .init(testing.allocator);
    defer now_map.deinit();

    var view = try buildCompareView(
        testing.allocator,
        Date.fromYmd(2026, 1, 1),
        Date.fromYmd(2026, 1, 1),
        true,
        0.0,
        0.0,
        &then_map,
        &now_map,
    );
    defer view.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 0), view.gainer_count);
    try testing.expectEqual(@as(usize, 0), view.loser_count);
    try testing.expectEqual(@as(usize, 0), view.flat_count);
}

// ── Layout constants + row-cell builders ─────────────────────
//
// Shared between the CLI and TUI renderers. Before this section
// existed, both renderers duplicated the column widths, format
// strings, and money/percent formatting — which is exactly the drift
// hazard `views/history.zig` was built to prevent. Every width or
// label change now lives here.

/// Symbol column width — fits "BRK-B" + a note-derived CUSIP label
/// like "TGT2035" with slack.
pub const symbol_w: usize = 8;
/// Per-price column width. Fits "$999,999.99".
pub const price_w: usize = 10;
/// Percent column width. Fits "+999.99%".
pub const pct_w: usize = 8;
/// Signed-dollar column width. Fits "+$99,999,999.99" with slack.
pub const dollar_w: usize = 14;
/// Transition glyph between the `then` and `now` cells.
pub const arrow: []const u8 = " → ";

// Comptime-built format specifiers so callers don't hardcode widths
// that might drift from the constants above. `cp` stringifies the
// width into a real Zig format spec; both CLI and TUI renderers
// compose these into their own line templates.
const cp = std.fmt.comptimePrint;
pub const symbol_fmt = cp("{{s:<{d}}}", .{symbol_w});
pub const price_left_fmt = cp("{{s:<{d}}}", .{price_w});
pub const price_right_fmt = cp("{{s:>{d}}}", .{price_w});
pub const pct_fmt = cp("{{s:>{d}}}", .{pct_w});
pub const dollar_fmt = cp("{{s:>{d}}}", .{dollar_w});

/// Single-color per-symbol row template. Ordered fields:
///   { symbol, price_then, arrow, price_now, pct, dollar }
/// Used by the TUI renderer (one style per line); the CLI composes
/// the row from smaller pieces to get per-segment coloring.
pub const symbol_row_fmt = symbol_fmt ++ " " ++ price_right_fmt ++
    "{s}" ++ price_left_fmt ++ "  " ++ pct_fmt ++ "  " ++ dollar_fmt;

/// Pre-formatted cells for a single per-symbol row. Strings borrow
/// from the caller-owned buffers passed into `buildSymbolRowCells`.
///
/// `style` is a semantic intent; renderers map to their own style
/// system (CLI: ANSI via `cli.setStyleIntent`, TUI: vaxis via
/// `theme.styleFor`).
pub const SymbolRowCells = struct {
    symbol: []const u8,
    price_then: []const u8,
    price_now: []const u8,
    pct: []const u8,
    dollar: []const u8,
    style: StyleIntent,
};

/// Build a single SymbolChange into display-ready cells. The four
/// caller-owned buffers back the returned strings and must outlive
/// the result.
pub fn buildSymbolRowCells(
    s: SymbolChange,
    price_then_buf: *[24]u8,
    price_now_buf: *[24]u8,
    pct_buf: *[16]u8,
    dollar_buf: *[32]u8,
) SymbolRowCells {
    return .{
        .symbol = s.symbol,
        .price_then = std.fmt.bufPrint(price_then_buf, "{f}", .{Money.from(s.price_then)}) catch "$?",
        .price_now = std.fmt.bufPrint(price_now_buf, "{f}", .{Money.from(s.price_now)}) catch "$?",
        .pct = view_hist.fmtSignedPercentBuf(pct_buf, s.pct_change),
        .dollar = std.fmt.bufPrint(dollar_buf, "{f}", .{Money.from(s.dollar_change).signed()}) catch "$?",
        .style = s.style,
    };
}

/// Pre-formatted cells for the liquid totals line (then → now, delta,
/// pct). Strings borrow from caller-owned buffers.
pub const TotalsCells = struct {
    then: []const u8,
    now: []const u8,
    delta: []const u8,
    pct: []const u8,
    /// Style for the delta/pct portion; the then/now portion is
    /// typically rendered in a muted/secondary style so the delta
    /// stands out. CLI honors this split; the TUI applies `style` to
    /// the whole line for simplicity.
    style: StyleIntent,
};

pub fn buildTotalsCells(
    t: TotalsRow,
    then_buf: *[24]u8,
    now_buf: *[24]u8,
    delta_buf: *[32]u8,
    pct_buf: *[16]u8,
) TotalsCells {
    return .{
        .then = std.fmt.bufPrint(then_buf, "{f}", .{Money.from(t.then)}) catch "$?",
        .now = std.fmt.bufPrint(now_buf, "{f}", .{Money.from(t.now)}) catch "$?",
        .delta = std.fmt.bufPrint(delta_buf, "{f}", .{Money.from(t.delta).signed()}) catch "$?",
        .pct = view_hist.fmtSignedPercentBuf(pct_buf, t.pct),
        .style = t.style,
    };
}

/// Format the "now" side label for the header. Snapshot-now shows
/// the date alone; live-now shows the date with a `(live)` marker
/// so readers comparing this run against a later run (which would
/// read today's value from a snapshot file) understand why the
/// numbers might drift slightly.
///
/// **Why the marker matters.** When `compare 1W` runs with a live
/// "now", the value it shows for "now" is computed against today's
/// state of `portfolio.srf` plus today's cached prices. Next week,
/// when the same user runs `compare 1W` again, this week's value
/// becomes "then" — but is read from the snapshot file (e.g.
/// `history/<DATE>-portfolio.srf`) that was captured for the
/// snapshot's `as_of` date, not the date the user actually ran
/// `compare`. The two values can disagree slightly because:
///
///   - **Date-arg drift.** Live `compare` on day T evaluates
///     `positionsForAccount(today=T)`, `totalCash(T)`, etc.
///     `snapshot --as-of T-1` evaluates the same against T-1. Any
///     lot whose `open_date` or `close_date` falls between those
///     two dates contributes a non-zero delta even when prices are
///     identical (e.g. a Saturday-dated RSU credit that's
///     in-scope for live Saturday-`compare` but not for the prior
///     Friday's snapshot).
///   - **Working-copy edits between runs.** Edits to
///     `portfolio.srf` made after the live `compare` run but
///     before the next `snapshot` capture are reflected in the
///     snapshot but not in the prior live "now" value (or vice
///     versa).
///   - **Cache refresh on a trading day.** When markets are open,
///     cached candle prices may refresh between the live `compare`
///     and the eventual `snapshot` capture. (On weekends and
///     holidays this source vanishes; the other two still apply.)
///
/// The `(live)` marker tells the reader "this number is ephemeral —
/// the corresponding snapshot value may differ." Without it, users
/// reasonably assumed last week's "now" should equal this week's
/// "then" verbatim and were surprised by a few-thousand-dollar drift
/// on a multi-million-dollar portfolio.
///
/// `buf` backs both cases; caller must keep it alive.
pub fn nowLabel(cv: CompareView, buf: *[24]u8) []const u8 {
    if (cv.now_is_live) {
        return std.fmt.bufPrint(buf, "{f} (live)", .{cv.now_date}) catch "today (live)";
    }
    return std.fmt.bufPrint(buf, "{f}", .{cv.now_date}) catch "????-??-??";
}

/// Re-export of `format.dayPlural` so callers keep a single import.
/// The canonical implementation lives in `src/format.zig`.
pub const dayPlural = fmt.dayPlural;

test "buildSymbolRowCells: wires through the right formatters" {
    var p_then: [24]u8 = undefined;
    var p_now: [24]u8 = undefined;
    var p_pct: [16]u8 = undefined;
    var p_dollar: [32]u8 = undefined;

    const s = SymbolChange{
        .symbol = "FOO",
        .price_then = 100.00,
        .price_now = 110.00,
        .shares_held_throughout = 10,
        .pct_change = 0.10,
        .dollar_change = 100.0,
        .style = .positive,
    };
    const cells = buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);

    try testing.expectEqualStrings("FOO", cells.symbol);
    try testing.expectEqualStrings("$100.00", cells.price_then);
    try testing.expectEqualStrings("$110.00", cells.price_now);
    try testing.expectEqualStrings("+10.00%", cells.pct);
    try testing.expectEqualStrings("+$100.00", cells.dollar);
    try testing.expectEqual(StyleIntent.positive, cells.style);
}

test "buildTotalsCells: wires through the right formatters" {
    var b_then: [24]u8 = undefined;
    var b_now: [24]u8 = undefined;
    var b_delta: [32]u8 = undefined;
    var b_pct: [16]u8 = undefined;

    const t = buildTotalsRow(10_000, 10_500);
    const cells = buildTotalsCells(t, &b_then, &b_now, &b_delta, &b_pct);

    try testing.expectEqualStrings("$10,000.00", cells.then);
    try testing.expectEqualStrings("$10,500.00", cells.now);
    try testing.expectEqualStrings("+$500.00", cells.delta);
    try testing.expectEqualStrings("+5.00%", cells.pct);
    try testing.expectEqual(StyleIntent.positive, cells.style);
}

test "nowLabel: live shows date with (live) marker, snapshot shows date alone" {
    const cv_live = CompareView{
        .then_date = Date.fromYmd(2024, 1, 15),
        .now_date = Date.fromYmd(2024, 3, 15),
        .days_between = 60,
        .now_is_live = true,
        .liquid = buildTotalsRow(100, 100),
        .symbols = &.{},
        .held_count = 0,
        .added_count = 0,
        .removed_count = 0,
    };
    var buf: [24]u8 = undefined;
    try testing.expectEqualStrings("2024-03-15 (live)", nowLabel(cv_live, &buf));

    const cv_snap = CompareView{
        .then_date = Date.fromYmd(2024, 1, 15),
        .now_date = Date.fromYmd(2024, 3, 15),
        .days_between = 60,
        .now_is_live = false,
        .liquid = buildTotalsRow(100, 100),
        .symbols = &.{},
        .held_count = 0,
        .added_count = 0,
        .removed_count = 0,
    };
    try testing.expectEqualStrings("2024-03-15", nowLabel(cv_snap, &buf));
}

test "dayPlural: 1 day singular, everything else plural" {
    try testing.expectEqualStrings("", dayPlural(1));
    try testing.expectEqualStrings("s", dayPlural(0));
    try testing.expectEqualStrings("s", dayPlural(2));
    try testing.expectEqualStrings("s", dayPlural(60));
}
